163 Commits

Author SHA1 Message Date
f385fcdc28 fix: Reverts to inline on gitea, try non-inline on github. 2026-02-11 12:15:46 -05:00
af4d4e393a fix: Try without inline cache. 2026-02-11 12:05:18 -05:00
0f96c67058 fix: Try with inline cache mode. 2026-02-11 11:56:29 -05:00
fbee56e460 fix: Try without cache to ensure builds work. 2026-02-11 11:43:11 -05:00
a8286f9ce2 fix: Fix gitea build & push image. 2026-02-11 11:34:27 -05:00
e3a731e3fa fix: Fix gitea build & push image.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 11:23:10 -05:00
8e66d57994 fix: Fix gitea build & push image.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 11:03:53 -05:00
87c651eed9 fix: Fix gitea build & push image building mulitple images.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:50:14 -05:00
e8ab85a189 fix: Fix gitea build & push image building mulitple images.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:44:19 -05:00
1fcf331729 feat: Add image cache support to gitea workflow, for quicker image builds.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:41:39 -05:00
3f0a669b2b fix: Fix release workflow in gitea to not build multi platform images.
All checks were successful
CI / Linux Tests (push) Successful in 6m3s
2026-02-11 09:46:02 -05:00
07fff2cebd fix: Fix release workflow in gitea to not build multi platform images. 2026-02-11 09:45:12 -05:00
4b81d3bd1e fix: Fixes release workflow for multi-platform builds.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 09:01:10 -05:00
338ccc64df feat: Initial minimal docker compose and updates to release workflows.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 08:53:21 -05:00
729dc0ac55 feat: Updates to ci workflow files.
All checks were successful
CI / Linux Tests (push) Successful in 6m51s
2026-02-11 08:14:17 -05:00
86f08a4bb2 clean: Cleans up some files that aren't used.
All checks were successful
CI / Linux Tests (push) Successful in 5m51s
2026-02-10 17:02:01 -05:00
08e67ce308 feat: Adds privacy policy.
All checks were successful
CI / Linux Tests (push) Successful in 8m30s
2026-02-10 16:26:23 -05:00
dc9f51c04f feat: Adds level field to rooms, updates urls to point to public mirror of the project.
Some checks failed
CI / Linux Tests (push) Failing after 15s
2026-02-10 12:07:44 -05:00
980d99e40b feat: Adds ductulator button to logged in views.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-02-09 16:58:28 -05:00
06b663052e feat: Renames quick calc routes / views to ductulator. Adds button to home page for using ductulator, needs added to navbar still. 2026-02-09 16:36:24 -05:00
007d13be2f feat: Adds quick calculation views, need to add buttons / links in navbar / home page. 2026-02-09 15:34:28 -05:00
88af6f722e feat: Adds logout route and switches user navbar item to dropdown menu. 2026-02-09 12:32:30 -05:00
5a7cf4714b feat: Adds multi-select option for selecting rooms in trunk sizing form. 2026-02-09 11:55:11 -05:00
e4ddec0d53 feat: Updates to home / landing page.
All checks were successful
CI / Linux Tests (push) Successful in 6m32s
2026-02-08 19:12:52 -05:00
bb88d48eb3 feat: Updates tests to include home page snapshot test, updates TODO's.
All checks were successful
CI / Linux Tests (push) Successful in 5m52s
2026-02-08 10:27:23 -05:00
d957cc1c19 feat: Adds minimal home page, change license to cc-by-nc-sa license in prep for public availablility.
Some checks failed
CI / Linux Tests (push) Failing after 5m41s
2026-02-08 10:14:19 -05:00
2aaa408712 fix: Fixes user not automatically being logged in upon creation. 2026-02-07 21:29:00 -05:00
291bed28d5 feat: Adds minimal cli executable and commands.
All checks were successful
CI / Linux Tests (push) Successful in 6m44s
2026-02-07 21:17:29 -05:00
1a38922ac0 fix: Fixes gitignore to not ignore rooms.csv test resource.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-02-07 18:26:14 -05:00
76bd788769 feat: Adds createFromCSV to create rooms in the database, properly handling delegating airflow to another room.
Some checks failed
CI / Linux Tests (push) Failing after 5m29s
2026-02-07 18:16:01 -05:00
0134c9bfc2 WIP: Updates test html snapshots, working on validation when delegating airflow to a different room.
All checks were successful
CI / Linux Tests (push) Successful in 5m39s
2026-02-06 17:07:06 -05:00
0775474f57 WIP: Updates test html snapshots, working on validation when delegating airflow to a different room.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-06 17:01:43 -05:00
f2c79ad56f WIP: Adds database field to delegate airflow to another room, adds select to room form.
Some checks failed
CI / Linux Tests (push) Failing after 6m39s
2026-02-06 12:11:01 -05:00
728a6c3000 feat: Adds CSV upload form to room view. 2026-02-06 08:22:59 -05:00
57766c990e feat: Initial csv parsing for uploading rooms for a project. Need to style the upload form.
All checks were successful
CI / Linux Tests (push) Successful in 5m41s
2026-02-05 16:39:40 -05:00
b2b5e32535 feat: Experiments with csv parsing / printing, currently implemented in RoomTests only. 2026-02-05 11:43:48 -05:00
881737978d feat: Experiments with csv parsing / printing, currently implemented in RoomTests only. 2026-02-05 11:39:57 -05:00
6a764ade2b feat: Update room cooling load to accept either total or sensible loads, instead of requiring a total load. 2026-02-05 09:44:37 -05:00
5f03056534 feat: Adds createMany for rooms, in prep for parsing / uploading a csv file of room loads.
All checks were successful
CI / Linux Tests (push) Successful in 7m0s
2026-02-04 21:06:05 -05:00
10dd0dac82 feat: Updates todos.
All checks were successful
CI / Linux Tests (push) Successful in 6m38s
2026-02-04 16:59:27 -05:00
3cc7fe9926 feat: Updates environment variables and datbase to allow postgres configuration for production environments.
All checks were successful
CI / Linux Tests (push) Successful in 6m39s
2026-02-03 09:41:31 -05:00
bad4a49f41 feat: Adds devcontainer
All checks were successful
CI / Linux Tests (push) Successful in 6m36s
2026-02-01 22:01:23 -05:00
9276f88426 feat: Updates to use swift-validations for database.
All checks were successful
CI / Linux Tests (push) Successful in 6m28s
2026-02-01 00:55:44 -05:00
a3fb87f86e feat: Removes api routes and controller as they're currently not used.
All checks were successful
CI / Linux Tests (push) Successful in 5m30s
2026-01-30 17:10:14 -05:00
b359a3317f feat: Adds trunk size database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m33s
2026-01-30 16:52:12 -05:00
e0ec15b91e feat: Adds rooms database tests. 2026-01-30 15:45:54 -05:00
44a0964181 feat: Adds equivalent length database tests. 2026-01-30 15:28:25 -05:00
0b78950d14 feat: Adds equipment info database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 15:16:09 -05:00
754019eac4 feat: Adds component loss database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m26s
2026-01-30 14:15:50 -05:00
a51e1b34d0 feat: Adds project database tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-30 14:02:58 -05:00
c32ffcff8c feat: Begins live database client tests.
All checks were successful
CI / Linux Tests (push) Successful in 5m35s
2026-01-30 12:02:11 -05:00
4f3cc2c7ea WIP: Cleans up ManualDClient and adds some more document strings.
Some checks failed
CI / Linux Tests (push) Failing after 7m3s
2026-01-29 22:56:39 -05:00
9b618d55fa WIP: Adds more tagged types for rectangular size calculations. 2026-01-29 20:50:33 -05:00
9379774fae feat: Begin using Tagged types
All checks were successful
CI / Linux Tests (push) Successful in 5m23s
2026-01-29 17:10:35 -05:00
18a5ef06d3 feat: Rename items in database client for consistency.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-29 15:47:24 -05:00
6723f7a410 feat: Adds some documentation strings in ManualDCore module. 2026-01-29 15:16:26 -05:00
5440024038 feat: Renames EffectiveLength to EquivalentLength
All checks were successful
CI / Linux Tests (push) Successful in 9m34s
2026-01-29 11:25:20 -05:00
f005b43936 feat: Try to build image in ci instead of relying on just.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-01-29 10:52:07 -05:00
f44b35ab3d feat: Try extractions/setup-just
Some checks failed
CI / Linux Tests (push) Failing after 7s
2026-01-29 10:46:00 -05:00
bbf9a8b390 feat: Setup just directly in ci workflow
Some checks failed
CI / Linux Tests (push) Failing after 10s
2026-01-29 10:43:49 -05:00
c52cee212f feat: Adds ci workflow.
Some checks failed
CI / Linux Tests (push) Failing after 5s
2026-01-29 10:36:29 -05:00
2e2c424850 Merge branch 'feat-pdf' 2026-01-29 09:34:41 -05:00
93894e4c25 feat: Adds pdf client tests, updates view controller snapshots that have changed. 2026-01-29 09:33:43 -05:00
bab031f241 feat: Adds pdf support to docker images. 2026-01-28 16:41:12 -05:00
c82f20bb60 WIP: Mostly done with pdf client, need to add tests. 2026-01-28 15:47:56 -05:00
458b3bd644 feat: Adds EnvClient 2026-01-28 11:34:52 -05:00
58023c4dbc feat: Adds view snapshot tests 2026-01-28 10:26:59 -05:00
30241fec60 feat: Updates pdf css to a smaller font size. 2026-01-27 14:16:25 -05:00
273da46db2 feat: Adds file client. 2026-01-27 13:50:42 -05:00
6064b5267a WIP: Initial pdf generation and download, needs improvement and put somewhere different. 2026-01-27 12:53:55 -05:00
69e8acc5d8 WIP: Cleans up ResultView, fixes creating a new trunk size after changes to the database client, pdf button shows html now. 2026-01-27 10:12:51 -05:00
066b3003d0 WIP: Using project detail for duct size calculations, fix trunk sizes querying database for room data, it's now eagerly loaded. 2026-01-27 08:59:36 -05:00
1663c0a514 WIP: Working on a project detail database request to minimize database calls. 2026-01-26 16:43:16 -05:00
e08d896758 feat: Adds better mock support for models to aid in testing / viewing a mock project for the pdf client. 2026-01-26 13:39:27 -05:00
b3c6c27a96 feat: Updates database client migrations to be called as a function. 2026-01-26 11:13:29 -05:00
5fa11ae584 WIP: Style updates for pdf view. 2026-01-18 18:49:20 -05:00
04a7405ca4 WIP: Html view that prints to pdf ok. 2026-01-17 20:40:49 -05:00
0fe80d05c6 WIP: Begins work on pdf client. 2026-01-17 12:59:46 -05:00
3ec1ee2814 feat: Begins creating an auth client and integrates into view controller routes. 2026-01-16 17:04:05 -05:00
761ba29c1e feat: Cleans up / renames some manual-d client routes. 2026-01-16 12:10:33 -05:00
13c4bb33b5 feat: Reorganizes / creates duct sizes container, uses it in views and projectClient. 2026-01-16 11:40:21 -05:00
146baa7815 feat: Moves TrunkSize to be it's own namespace rather than being under DuctSizing, as it's got it's own database model, etc. 2026-01-16 10:48:07 -05:00
b5436c2073 feat: Moves rectangular size to room namespace instead of under duct sizing, since it's stored on the room database model. 2026-01-16 10:26:11 -05:00
59c1c9ec4a feat: Uses shared duct size container for both room and trunk duct sizing containers. 2026-01-16 10:16:31 -05:00
65fc8565b6 feat: More cleanup / renaming for project client. 2026-01-16 09:40:15 -05:00
d14477e97a feat: Cleans up / moves helpers for view controller to project client. 2026-01-16 09:31:35 -05:00
dbec7fb920 WIP: Attempt at breaking out some logic / middleware between database and view layer, to remove some code from the view controller. Not complete, maybe revert. 2026-01-15 23:02:36 -05:00
6b8cb73434 feat: Adds TODO's. 2026-01-15 19:51:26 -05:00
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
930db145a8 WIP: Begins trunk sizing, adds database and core models. 2026-01-13 11:45:27 -05:00
df600a5471 feat: Updates forms to use LabeledInput, style updates. 2026-01-13 10:15:06 -05:00
432533c940 feat-WIP: Style updates, new form inputs. 2026-01-12 22:49:58 -05:00
fa9e8cffb0 feat: Fixes signup flow, begins updating form input fields. 2026-01-12 18:53:45 -05:00
c2aedfac1a WIP: Begins updating signup route to also setup a user's profile. 2026-01-12 17:04:51 -05:00
894bd561ff feat: Begins user profile, adds database model, need to add views / forms. 2026-01-12 13:33:53 -05:00
6416b29627 feat: Removes unused form routes. 2026-01-12 11:32:38 -05:00
6aaf39f63c fix: Fixes database going out of scope when rendering project view. 2026-01-12 10:58:57 -05:00
0a68177aa8 feat: Style updates. 2026-01-11 20:57:06 -05:00
f7c6373255 feat: Adds initial icons / favicon 2026-01-11 13:48:30 -05:00
f835fc7c51 feat: Initial navbar 2026-01-11 12:41:54 -05:00
51edff5a8a feat: Updates sidebar styles. 2026-01-11 10:35:49 -05:00
a7f40efba9 feat: Updates button styles. 2026-01-10 20:37:57 -05:00
1446540109 feat: Updates to using ResultView to handle errors. 2026-01-10 20:14:59 -05:00
20065ebf10 feat: Adds result view to better handle errors, integrates it into project view. 2026-01-10 18:27:45 -05:00
a356aa2a13 feat: Updates rectangular size to be a modal form, some style updates to other views. 2026-01-10 14:04:23 -05:00
07818d24ed WIP: Inital duct rectangular form, needs better thought out. 2026-01-09 17:03:00 -05:00
7083178844 feat: Initial duct sizing view and calculations, need to add supply and return trunk sizing. 2026-01-09 12:43:56 -05:00
30fddb9dce feat: Updates form routes and database routes to use id's in the url path. 2026-01-09 09:25:37 -05:00
9356ccb1c9 feat: Updates sidebar to use the drawer classes from daisyui, currently doesn't open automatically on large screens like I want. 2026-01-08 12:40:05 -05:00
79b7892d9a feat: Adds update path to equivalent length form / database / view routes. 2026-01-07 17:31:54 -05:00
f8bed40670 feat: Adds multi-step form to generate equivalent lengths for a project. 2026-01-07 11:56:04 -05:00
dbf7e3b1b4 feat: Some style updates, form improvements on project-room view. 2026-01-06 16:58:42 -05:00
8fb313fddc feat: Adds sensible heat ratio for projects, adds initial view / forms to the rooms tab. 2026-01-06 12:19:14 -05:00
5fcc5b88fa feat: Better modal form using dialog, some forms still need updated to use it effectively. 2026-01-06 10:12:48 -05:00
fc12e47b5c feat: Working on adding updates to project form, it's currently not loading an existing project. 2026-01-05 17:04:25 -05:00
4c8a23be94 feat: Adds update and delete routes to room. 2026-01-05 15:59:23 -05:00
fb7cf9905c feat: Adds update route to equipment info, reorganizes views. 2026-01-05 11:27:20 -05:00
55a3adde25 WIP: Moves friction rate route to be part of project detail routes. 2026-01-05 09:01:49 -05:00
4aca134abd WIP: 2026-01-05 07:38:25 -05:00
f159c3ab75 feat: adds next route to login. 2026-01-04 09:30:14 -05:00
a61c772f7b WIP: Exploring different routes. 2026-01-03 19:03:04 -05:00
9f63b96c80 WIP: Working rooms table and form for project. 2026-01-03 17:02:21 -05:00
1aeb6144d5 WIP: Changes main page to not include sidebar, that moves to project view. 2026-01-03 16:24:53 -05:00
1d155546ae WIP: Working signup and login forms, along with initial view auth middleware. 2026-01-03 11:30:42 -05:00
6c6045b4a6 WIP: Updates to login / signup forms, rearranges some view routes. 2026-01-02 22:53:22 -05:00
6602c4a8b5 WIP: Begins work on login / signup, adds user database models, authentication needs implemented. 2026-01-02 17:00:50 -05:00
4750842a57 WIP: Adds daisyui. 2026-01-02 11:17:20 -05:00
89fdf0930b WIP: Updates rooms view. 2026-01-02 10:04:28 -05:00
54847d0b34 WIP: Adds a modal form view and integrates into current forms. 2026-01-02 08:27:31 -05:00
8fe650e142 WIP: Initial effective length views. 2026-01-01 18:41:48 -05:00
95d35f0392 WIP: Begins effective length views. 2026-01-01 15:35:55 -05:00
b116c3011b WIP: Adds initial effective length to database client. 2026-01-01 11:21:15 -05:00
7c37392390 WIP: Updates rooms views. 2026-01-01 10:12:02 -05:00
582d94d13b WIP: Sidebar improvements, working on other views. 2026-01-01 08:47:23 -05:00
24c87602e9 WIP: Working on friction rate worksheet views. 2025-12-31 21:56:43 -05:00
591875cf13 WIP: Begins friction rate views. 2025-12-31 17:07:57 -05:00
34bba7bdfc WIP: Moves some common views to a Styleguide module, working on room table and form. 2025-12-31 16:16:39 -05:00
c29e1acffe WIP: Begins rooms table. 2025-12-31 10:01:39 -05:00
231f1b8de6 WIP: Adds initial sidebar, needs more styling. 2025-12-30 20:22:53 -05:00
3e5c584d57 WIP: Adds initial sidebar, needs more styling. 2025-12-30 19:30:39 -05:00
f67c3ef847 WIP: Begins creating some project views. 2025-12-30 17:05:37 -05:00
2bbff896c9 feat: Adds docker support to start building views. 2025-12-30 13:41:25 -05:00
79ea188e07 feat: Fixes build issues, adds ApiController module. 2025-12-30 11:03:43 -05:00
4e1be161b1 feat: Begins vapor application, begins view controller. 2025-12-30 10:12:45 -05:00
6eedb7396d feat: Adds component pressure loss to database client and api routes. 2025-12-29 17:04:25 -05:00
a2514853a6 feat: Adds equipment info to database and api routes. 2025-12-29 16:31:57 -05:00
31930cd399 feat: Adds rooms database model. 2025-12-29 15:21:59 -05:00
201 changed files with 38775 additions and 602 deletions

View File

@@ -0,0 +1,40 @@
{
"name": "swift-manual-d-dev",
"image": "git.housh.dev/michael/swift-dev-container:latest",
"features": {
"ghcr.io/devcontainers/features/git:1": {
"version": "os-provided",
"ppa": "false"
},
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
"ghcr.io/rocker-org/devcontainer-features/pandoc:1": {},
//"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/wxw-matt/devcontainer-features/apt:latest": {
"packages": "weasyprint gnupg2 tmux"
},
"ghcr.io/swift-server-community/swift-devcontainer-features/jemalloc:1": { },
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
"remoteEnv": {
// Set TERM, prevents problems when "xterm-ghostty" get's passed from host,
// which causes weird typing and display problems.
"TERM": "xterm-256color",
// Less backtrace error warnings from swift.
"SWIFT_BACKTRACE": "enable=no"
},
"remoteUser": "swift",
"forwardPorts": [8080],
"mounts": [
{
"type": "bind",
"source": "${localEnv:HOME}/.local/share/nvim",
"target": "/home/swift/.local/share/nvim"
}
]
}

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.build/
.swiftpm/

31
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:
ubuntu:
name: Linux Tests
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.test
push: false
load: true
tags: michael/ductcalc:test
- name: Run Tests
run: |
docker run --rm michael/ductcalc:test swift test

View File

@@ -0,0 +1,61 @@
name: Create and publish a Docker image
on:
push:
# branches: ['main']
tags:
- '*.*.*'
workflow_dispatch:
env:
REGISTRY: git.housh.dev
USERNAME: michael
IMAGE_NAME: ${{ gitea.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CONTAINER_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major.minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=latest
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:build
cache-to: mode=min,image-manifest=true,oci-mediatypes=true,type=inline,ref=${{ env.IMAGE_NAME }}:build

31
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches:
- main
- dev
pull_request:
workflow_dispatch:
jobs:
ubuntu:
name: Linux Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile.test
push: false
load: true
tags: ${{ github.repository_owner }}/ductcalc:test
- name: Run Tests
run: |
docker run --rm ${{ github.repository_owner }}/ductcalc:test swift test

73
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,73 @@
name: Create and publish a Docker image
on:
push:
# branches: ['main']
tags:
- '*.*.*'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
USERNAME: m-housh
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ env.USERNAME }}
password: ${{ secrets.CONTAINER_TOKEN }}
- name: Set up Docker
uses: docker/setup-docker-action@v4
with:
daemon-config: |
{
"debug": true,
"features": {
"containerd-snapshotter": true
}
}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.USERNAME }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major.minor}}
type=semver,pattern={{major}}
type=sha
type=raw,value=latest
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:build

9
.gitignore vendored
View File

@@ -6,3 +6,12 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.swift-version
node_modules/
tailwindcss
.envrc
*.pdf
.env
.env*
default.profraw
/rooms.csv

394
LICENSE
View File

@@ -1,18 +1,384 @@
MIT License
Copyright (c) 2026 Michael Housh II
Copyright (c) 2025 michael
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

View File

@@ -1,5 +1,5 @@
{
"originHash" : "f8ca659e4ec9041ea590b94c8dc267718be8941a4594eb74964b6396e987ca95",
"originHash" : "5bbd172c602e6484b32782f8cb68faca2d5120adc511acdff74e62fec2178c15",
"pins" : [
{
"identity" : "async-http-client",
@@ -37,6 +37,24 @@
"version" : "4.15.2"
}
},
{
"identity" : "elementary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/elementary-swift/elementary.git",
"state" : {
"revision" : "65803c4770b6c8b6452c6ce83ab570c1b7042528",
"version" : "0.6.1"
}
},
{
"identity" : "elementary-htmx",
"kind" : "remoteSourceControl",
"location" : "https://github.com/elementary-swift/elementary-htmx.git",
"state" : {
"revision" : "f7ba147f2a076142a90a4f8300a89584164dba6d",
"version" : "0.5.1"
}
},
{
"identity" : "fluent",
"kind" : "remoteSourceControl",
@@ -55,6 +73,15 @@
"version" : "1.53.0"
}
},
{
"identity" : "fluent-postgres-driver",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
"state" : {
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
"version" : "2.12.0"
}
},
{
"identity" : "fluent-sqlite-driver",
"kind" : "remoteSourceControl",
@@ -82,6 +109,24 @@
"version" : "0.14.0"
}
},
{
"identity" : "postgres-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-kit.git",
"state" : {
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
"version" : "2.15.1"
}
},
{
"identity" : "postgres-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/postgres-nio.git",
"state" : {
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
"version" : "1.30.1"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
@@ -127,6 +172,15 @@
"version" : "1.2.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
@@ -208,6 +262,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",
@@ -343,6 +406,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",
@@ -361,6 +433,15 @@
"version" : "1.6.3"
}
},
{
"identity" : "swift-tagged",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-tagged",
"state" : {
"revision" : "3907a9438f5b57d317001dc99f3f11b46882272b",
"version" : "0.10.0"
}
},
{
"identity" : "swift-url-routing",
"kind" : "remoteSourceControl",
@@ -370,6 +451,15 @@
"version" : "0.6.2"
}
},
{
"identity" : "swift-validations",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-validations.git",
"state" : {
"revision" : "ae939c146f380ca12d0a04ca1f6b0c4c270fdd5a",
"version" : "0.3.5"
}
},
{
"identity" : "vapor",
"kind" : "remoteSourceControl",
@@ -379,6 +469,24 @@
"version" : "4.120.0"
}
},
{
"identity" : "vapor-elementary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor-community/vapor-elementary.git",
"state" : {
"revision" : "e1326d2439a9d4bd271c24b9765c195f7f793e77",
"version" : "0.2.2"
}
},
{
"identity" : "vapor-routing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/vapor-routing.git",
"state" : {
"revision" : "ae1db2ec96fad88b00173a265313de2c447a9945",
"version" : "0.1.3"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",

View File

@@ -5,23 +5,87 @@ import PackageDescription
let package = Package(
name: "swift-manual-d",
products: [
.executable(name: "App", targets: ["App"]),
.executable(name: "ductcalc", targets: ["CLI"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "CSVParser", targets: ["CSVParser"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvVars", targets: ["EnvVars"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]),
.library(name: "ProjectClient", targets: ["ProjectClient"]),
.library(name: "ManualDCore", targets: ["ManualDCore"]),
.library(name: "ManualDClient", targets: ["ManualDClient"]),
.library(name: "Styleguide", targets: ["Styleguide"]),
.library(name: "ViewController", targets: ["ViewController"]),
],
dependencies: [
// 💧 A server-side Swift web framework.
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
// 🗄 An ORM for SQL and NoSQL databases.
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
// 🪶 Fluent driver for SQLite.
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
// 🔵 Non-blocking, event-driven networking Swift. Used for, custom executors
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
.package(url: "https://github.com/pointfreeco/swift-tagged", from: "0.6.0"),
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.3.5"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Vapor", package: "vapor"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "VaporElementary", package: "vapor-elementary"),
.product(name: "VaporRouting", package: "vapor-routing"),
]
),
.executableTarget(
name: "CLI",
dependencies: [
.target(name: "ManualDClient"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.target(
name: "AuthClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "CSVParser",
dependencies: [
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.testTarget(
name: "CSVParsingTests",
dependencies: [
.target(name: "CSVParser")
]
),
.target(
name: "DatabaseClient",
dependencies: [
@@ -30,22 +94,99 @@ let package = Package(
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "Vapor", package: "vapor"),
.product(name: "Validations", package: "swift-validations"),
]
),
.testTarget(
name: "DatabaseClientTests",
dependencies: [
.target(name: "App"),
.target(name: "DatabaseClient"),
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
],
resources: [
.copy("Resources")
]
),
.target(
name: "EnvVars",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "FileClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "HTMLSnapshotTesting",
dependencies: [
.product(name: "Elementary", package: "elementary"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
),
.target(
name: "PdfClient",
dependencies: [
.target(name: "EnvVars"),
.target(name: "FileClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
]
),
.testTarget(
name: "PdfClientTests",
dependencies: [
.target(name: "HTMLSnapshotTesting"),
.target(name: "PdfClient"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
],
resources: [
.copy("__Snapshots__")
]
),
.target(
name: "ProjectClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "PdfClient"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "ManualDCore",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "Tagged", package: "swift-tagged"),
.product(name: "URLRouting", package: "swift-url-routing"),
]
),
.target(
name: "ManualDClient",
dependencies: [
"ManualDCore",
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Tagged", package: "swift-tagged"),
]
),
.target(
name: "Styleguide",
dependencies: [
"ManualDCore",
.product(name: "Elementary", package: "elementary"),
.product(name: "ElementaryHTMX", package: "elementary-htmx"),
]
),
.testTarget(
@@ -55,10 +196,32 @@ let package = Package(
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
]
),
.testTarget(
name: "ApiRouteTests",
.target(
name: "ViewController",
dependencies: [
.target(name: "ManualDCore")
.target(name: "AuthClient"),
.target(name: "CSVParser"),
.target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
.product(name: "ElementaryHTMX", package: "elementary-htmx"),
.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;
}

4
Public/css/main.css Normal file
View File

@@ -0,0 +1,4 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: all;
}

11534
Public/css/output.css Normal file

File diff suppressed because it is too large Load Diff

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.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
Public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
Public/images/mand_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

File diff suppressed because it is too large Load Diff

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;
}
},
});

0
Public/js/main.js Normal file
View File

1
Public/site.webmanifest Normal file
View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -1,2 +1,2 @@
# swift-manual-d
# swift-duct-calc

View File

@@ -0,0 +1,17 @@
import Foundation
import Vapor
#if DEBUG
struct BrowserSyncHandler: LifecycleHandler {
func didBoot(_ application: Application) throws {
let process = Process()
process.executableURL = URL(filePath: "/bin/sh")
process.arguments = ["-c", "browser-sync reload"]
do {
try process.run()
} catch {
print("Could not auto-reload: \(error)")
}
}
}
#endif

View File

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

View File

@@ -0,0 +1,7 @@
import Vapor
extension Request {
var isHtmxRequest: Bool {
headers.contains(name: "hx-request")
}
}

View File

@@ -0,0 +1,56 @@
import Elementary
import ManualDCore
import Vapor
import VaporElementary
import ViewController
extension ViewController {
func respond(route: SiteRoute.View, request: Vapor.Request) async throws
-> any AsyncResponseEncodable
{
let html = try await view(
.init(
route: route,
isHtmxRequest: request.isHtmxRequest,
logger: request.logger
)
)
return AnyHTMLResponse(value: html)
}
}
// Re-adapted from `HTMLResponse` in the VaporElementary package to work with any html types
// returned from the view controller.
struct AnyHTMLResponse: AsyncResponseEncodable {
public var chunkSize: Int
public var headers: HTTPHeaders = ["Content-Type": "text/html; charset=utf-8"]
var value: _SendableAnyHTMLBox
init(chunkSize: Int = 1024, additionalHeaders: HTTPHeaders = [:], value: AnySendableHTML) {
self.chunkSize = chunkSize
if additionalHeaders.contains(name: .contentType) {
self.headers = additionalHeaders
} else {
headers.add(contentsOf: additionalHeaders)
}
self.value = .init(value)
}
func encodeResponse(for request: Request) async throws -> Response {
Response(
status: .ok,
headers: headers,
body: .init(asyncStream: { [value, chunkSize] writer in
guard let html = value.tryTake() else {
assertionFailure("Non-sendable HTML value consumed more than once")
request.logger.error("Non-sendable HTML value consumed more than once")
throw Abort(.internalServerError)
}
try await writer.writeHTML(html, chunkSize: chunkSize)
try await writer.write(.end)
})
)
}
}

View File

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

View File

@@ -0,0 +1,100 @@
import URLRouting
import Vapor
import VaporRouting
// Taken from github.com/nevillco/vapor-routing
extension Application {
/// Mounts a router to the Vapor application.
///
/// See ``VaporRouting`` for more information on usage.
///
/// - Parameters:
/// - router: A parser-printer that works on inputs of `URLRequestData`.
/// - middleware: A closure for providing any per-route migrations to be run before processing the request.
/// - closure: A closure that takes a `Request` and the router's output as arguments.
public func mount<R: Parser>(
_ router: R,
middleware: @escaping @Sendable (R.Output) -> [any Middleware]? = { _ in nil },
use closure: @escaping @Sendable (Request, R.Output) async throws -> any AsyncResponseEncodable
) where R.Input == URLRequestData, R: Sendable, R.Output: Sendable {
self.middleware.use(
AsyncRoutingMiddleware(router: router, middleware: middleware, respond: closure))
}
}
/// Serves requests using a router and response handler.
///
/// You will not typically need to interact with this type directly. Instead you should use the
/// `mount` method on your Vapor application.
///
/// See ``VaporRouting`` for more information on usage.
public struct AsyncRoutingMiddleware<Router: Parser>: AsyncMiddleware
where
Router.Input == URLRequestData,
Router: Sendable,
Router.Output: Sendable
{
let router: Router
let middleware: @Sendable (Router.Output) -> [any Middleware]?
let respond: @Sendable (Request, Router.Output) async throws -> any AsyncResponseEncodable
public func respond(
to request: Request,
chainingTo next: any AsyncResponder
) async throws -> Response {
if request.body.data == nil {
try await _ = request.body.collect(max: request.application.routes.defaultMaxBodySize.value)
.get()
}
guard let requestData = URLRequestData(request: request)
else { return try await next.respond(to: request) }
let route: Router.Output
do {
route = try router.parse(requestData)
} catch let routingError {
do {
return try await next.respond(to: request)
} catch {
request.logger.info("\(routingError)")
guard request.application.environment == .development
else { throw error }
return Response(status: .notFound, body: .init(string: "Routing \(routingError)"))
}
}
if let middleware = middleware(route) {
return try await middleware.makeResponder(
chainingTo: AsyncBasicResponder { request in
try await self.respond(request, route).encodeResponse(for: request)
}
).respond(to: request).get()
// return try await middleware.respond(
// to: request,
// chainingTo: AsyncBasicResponder { request in
// try await self.respond(request, route).encodeResponse(for: request)
// }
// ).get()
} else {
return try await respond(request, route).encodeResponse(for: request)
}
}
}
// Usage:
// app.mount(
// router,
// middleware: { route in
// case .onboarding: return nil
// case .signIn: return BasicAuthMiddleware()
// default: return BearerAuthMiddleware()
// },
// use: { request, route in
// // route handline
// }
// )

View File

@@ -0,0 +1,23 @@
import DatabaseClient
import Fluent
import ManualDCore
import Vapor
private let viewRouteMiddleware: [any Middleware] = [
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware { req in
"/login?next=\(req.url.string)"
},
]
extension SiteRoute.View {
var middleware: [any Middleware]? {
switch self {
case .home, .login, .signup, .test, .ductulator, .privacyPolicy:
return nil
case .project, .user:
return viewRouteMiddleware
}
}
}

171
Sources/App/configure.swift Normal file
View File

@@ -0,0 +1,171 @@
import DatabaseClient
import Dependencies
import Elementary
import EnvVars
import Fluent
import FluentPostgresDriver
import FluentSQLiteDriver
import ManualDCore
import NIOSSL
import ProjectClient
import Vapor
import VaporElementary
@preconcurrency import VaporRouting
import ViewController
// configures your application
public func configure(
_ app: Application,
in environment: EnvVars,
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
) async throws {
// Setup the database client.
let databaseClient = try await setupDatabase(
on: app, environment: environment, factory: makeDatabaseClient
)
// Add the global middlewares.
addMiddleware(to: app, database: databaseClient, environment: environment)
#if DEBUG
// Live reload of the application for development when launched with the `./swift-dev` command
// app.lifecycle.use(BrowserSyncHandler())
#endif
// Add our route handlers.
addRoutes(to: app)
if app.environment != .testing {
try await app.autoMigrate()
}
// Add our custom cli-commands to the application.
addCommands(to: app)
}
private func addMiddleware(
to app: Application,
database databaseClient: DatabaseClient,
environment: EnvVars
) {
// cors middleware should come before default error middleware using `at: .beginning`
let corsConfiguration = CORSMiddleware.Configuration(
allowedOrigin: .all,
allowedMethods: [.GET, .POST, .PUT, .OPTIONS, .DELETE, .PATCH],
allowedHeaders: [
.accept, .authorization, .contentType, .origin,
.xRequestedWith, .userAgent, .accessControlAllowOrigin,
]
)
let cors = CORSMiddleware(configuration: corsConfiguration)
app.middleware.use(cors, at: .beginning)
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
app.middleware.use(app.sessions.middleware)
app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
}
private func setupDatabase(
on app: Application,
environment: EnvVars,
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
) async throws -> DatabaseClient {
switch app.environment {
case .production:
let configuration = try environment.postgresConfiguration()
app.databases.use(.postgres(configuration: configuration), as: .psql)
case .development:
let dbFileName = environment.sqlitePath ?? "db.sqlite"
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
default:
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
}
let databaseClient = makeDatabaseClient(app.db)
try await app.migrations.add(databaseClient.migrations())
return databaseClient
}
private func addRoutes(to app: Application) {
// Redirect the index path to project route.
app.get { req in
req.redirect(to: SiteRoute.View.router.path(for: .project(.index)))
}
app.mount(
SiteRoute.router,
middleware: {
if app.environment == .testing {
return nil
} else {
return $0.middleware()
}
},
use: siteHandler
)
}
private func addCommands(to app: Application) {
// #if DEBUG
// app.asyncCommands.use(SeedCommand(), as: "seed")
// #endif
// app.asyncCommands.use(GenerateAdminUserCommand(), as: "generate-admin")
}
extension SiteRoute {
fileprivate func middleware() -> [any Middleware]? {
switch self {
case .health:
return nil
case .view(let route):
return route.middleware
}
}
}
extension DuctSizes: Content {}
@Sendable
private func siteHandler(
request: Request,
route: SiteRoute
) async throws -> any AsyncResponseEncodable {
@Dependency(\.viewController) var viewController
@Dependency(\.projectClient) var projectClient
switch route {
case .health:
return HTTPStatus.ok
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
// need to handle it seperately.
case .view(.project(.detail(let projectID, .pdf))):
return try await projectClient.generatePdf(projectID)
case .view(let route):
return try await viewController.respond(route: route, request: request)
}
}
extension EnvVars {
func postgresConfiguration() throws -> SQLPostgresConfiguration {
guard let hostname = postgresHostname,
let username = postgresUsername,
let password = postgresPassword,
let database = postgresDatabase
else {
throw EnvError("Missing environment variables for postgres connection.")
}
return .init(
hostname: hostname,
username: username,
password: password,
database: database,
tls: .disable
)
}
}
struct EnvError: Error {
let reason: String
init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -0,0 +1,39 @@
import DatabaseClient
import Dependencies
import EnvVars
import Logging
import NIOCore
import NIOPosix
import Vapor
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
// This attempts to install NIO as the Swift Concurrency global executor.
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
let executorTakeoverSuccess =
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
app.logger.debug(
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
)
do {
try await configure(app, in: EnvVars.live())
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.execute()
try await app.asyncShutdown()
}
}

View File

@@ -0,0 +1,58 @@
import DatabaseClient
import Dependencies
import DependenciesMacros
import ManualDCore
import Vapor
extension DependencyValues {
/// Authentication dependency, for handling authentication tasks.
public var auth: AuthClient {
get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue }
}
}
/// Represents authentication tasks that are used in the application.
@DependencyClient
public struct AuthClient: Sendable {
/// Create a new user and log them in.
public var createAndLogin: @Sendable (User.Create) async throws -> User
/// Get the current user.
public var currentUser: @Sendable () throws -> User
/// Login a user.
public var login: @Sendable (User.Login) async throws -> User
/// Logout a user.
public var logout: @Sendable () throws -> Void
}
extension AuthClient: TestDependencyKey {
public static let testValue = Self()
public static func live(on request: Request) -> Self {
@Dependency(\.database) var database
return .init(
createAndLogin: { createForm in
let user = try await database.users.create(createForm)
_ = try await database.users.login(
.init(email: createForm.email, password: createForm.password)
)
request.auth.login(user)
request.session.authenticate(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
currentUser: {
try request.auth.require(User.self)
},
login: { loginForm in
let token = try await database.users.login(loginForm)
let user = try await database.users.get(token.userID)!
request.session.authenticate(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
logout: { request.auth.logout(User.self) }
)
}
}

14
Sources/CLI/Cli.swift Normal file
View File

@@ -0,0 +1,14 @@
import ArgumentParser
@main
struct DuctCalcCli: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "ductcalc",
abstract: "Perform duct calculations.",
subcommands: [
ConvertCommand.self,
SizeCommand.self,
],
defaultSubcommand: SizeCommand.self
)
}

View File

@@ -0,0 +1,35 @@
import ArgumentParser
import Dependencies
import ManualDClient
struct ConvertCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "convert",
abstract: "Convert to an equivalent recangular size."
)
@Option(
name: .shortAndLong,
help: "The height"
)
var height: Int
@Argument(
// name: .shortAndLong,
help: "The round size."
)
var roundSize: Int
func run() async throws {
@Dependency(\.manualD) var manualD
let size = try await manualD.rectangularSize(
round: .init(roundSize),
height: .init(height)
)
print("\(size.width) x \(height)")
}
}

View File

@@ -0,0 +1,35 @@
import ArgumentParser
import Dependencies
import ManualDClient
struct SizeCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "size",
abstract: "Calculate the required size of a duct."
)
@Option(
name: .shortAndLong,
help: "The design friction rate."
)
var frictionRate: Double = 0.06
@Argument(
help: "The required CFM for the duct."
)
var cfm: Int
func run() async throws {
@Dependency(\.manualD) var manualD
let size = try await manualD.ductSize(cfm: cfm, frictionRate: frictionRate)
print(
"""
Calculated: \(size.calculatedSize.string(digits: 2))
Final Size: \(size.finalSize)
Flex Size: \(size.flexSize)
"""
)
}
}

View File

@@ -0,0 +1,42 @@
import Dependencies
import DependenciesMacros
import ManualDCore
import Parsing
extension DependencyValues {
public var csvParser: CSVParser {
get { self[CSVParser.self] }
set { self[CSVParser.self] = newValue }
}
}
@DependencyClient
public struct CSVParser: Sendable {
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.CSV.Row]
}
extension CSVParser: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
parseRooms: { csv in
guard let string = String(data: csv.file, encoding: .utf8) else {
throw CSVParsingError("Unreadable file data")
}
let rows = try RoomCSVParser().parse(string[...].utf8)
return rows.reduce(into: [Room.CSV.Row]()) {
if case .room(let room) = $1 {
$0.append(room)
}
}
}
)
}
public struct CSVParsingError: Error {
let reason: String
public init(_ reason: String) {
self.reason = reason
}
}

View File

@@ -0,0 +1,64 @@
import ManualDCore
import Parsing
struct RoomCSVParser: Parser {
var body: some Parser<Substring.UTF8View, [RoomRowType]> {
Many {
RoomRowParser()
} separator: {
"\n".utf8
}
}
}
struct RoomRowParser: Parser {
var body: some Parser<Substring.UTF8View, RoomRowType> {
OneOf {
RoomCreateParser().map { RoomRowType.room($0) }
Prefix { $0 != UInt8(ascii: "\n") }
.map(.string)
.map { RoomRowType.header($0) }
}
}
}
enum RoomRowType {
case header(String)
case room(Room.CSV.Row)
}
struct RoomCreateParser: ParserPrinter {
// FIX: The delegated to field won't work here, as we potentially have not created
// the room yet, so we will need an intermediate representation for the csv data
// that uses a room's name or disregard and require user to delegate airflow in
// the ui.
var body: some ParserPrinter<Substring.UTF8View, Room.CSV.Row> {
ParsePrint {
Prefix { $0 != UInt8(ascii: ",") }.map(.string)
",".utf8
Optionally {
Int.parser()
.map(.memberwise(Room.Level.init(rawValue:)))
}
",".utf8
Double.parser()
",".utf8
Optionally {
Double.parser()
}
",".utf8
Optionally {
Double.parser()
}
",".utf8
Int.parser()
",".utf8
Optionally {
Prefix { $0 != UInt8(ascii: "\n") }.map(.string)
}
}
.map(.memberwise(Room.CSV.Row.init))
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
// TODO: Move to ManualDCore
public struct ValidationError: Error {
public let message: String
@@ -8,4 +9,6 @@ public struct ValidationError: Error {
}
}
public struct NotFoundError: Error {}
public struct NotFoundError: Error {
public init() {}
}

View File

@@ -4,32 +4,160 @@ import FluentKit
import ManualDCore
extension DependencyValues {
/// The database dependency.
public var database: DatabaseClient {
get { self[DatabaseClient.self] }
set { self[DatabaseClient.self] = newValue }
}
}
/// Represents the database interactions used by the application.
@DependencyClient
public struct DatabaseClient: Sendable {
/// Database migrations.
public var migrations: Migrations
/// Interactions with the projects table.
public var projects: Projects
}
/// Interactions with the rooms table.
public var rooms: Rooms
/// Interactions with the equipment table.
public var equipment: Equipment
/// Interactions with the component losses table.
public var componentLosses: ComponentLosses
/// Interactions with the equivalent lengths table.
public var equivalentLengths: EquivalentLengths
/// Interactions with the users table.
public var users: Users
/// Interactions with the user profiles table.
public var userProfiles: UserProfiles
/// Interactions with the trunk sizes table.
public var trunkSizes: TrunkSizes
@DependencyClient
public struct ComponentLosses: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
@DependencyClient
public struct EquivalentLengths: Sendable {
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
public var update:
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
}
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public var all: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.all()
}
}
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Project.ID, Room.Create) async throws -> Room
public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room]
public var createFromCSV: @Sendable (Project.ID, [Room.CSV.Row]) async throws -> [Room]
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
@DependencyClient
public struct UserProfiles: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient: TestDependencyKey {
public static let testValue: DatabaseClient = Self(
migrations: .testValue,
projects: .testValue
projects: .testValue,
rooms: .testValue,
equipment: .testValue,
componentLosses: .testValue,
equivalentLengths: .testValue,
users: .testValue,
userProfiles: .testValue,
trunkSizes: .testValue
)
}
extension DatabaseClient.Migrations: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
migrations: .liveValue,
projects: .live(database: database),
rooms: .live(database: database),
equipment: .live(database: database),
componentLosses: .live(database: database),
equivalentLengths: .live(database: database),
users: .live(database: database),
userProfiles: .live(database: database),
trunkSizes: .live(database: database)
)
}
}

View File

@@ -0,0 +1,48 @@
import Validations
extension Validator {
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator())
}
static func validate<Child: Validatable>(
_ toChild: KeyPath<Value, [Child]?>
)
-> Self
{
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator().optional())
}
}
extension Array where Element: Validatable {
static func validator() -> some Validation<Self> {
ArrayValidator<Element>()
}
}
struct ArrayValidator<Element>: Validation where Element: Validatable {
func validate(_ value: [Element]) throws {
for item in value {
try item.validate()
}
}
}
struct ForEachValidator<T, E>: Validation where T: Validation, T.Value == E {
let validator: T
init(@ValidationBuilder<E> builder: () -> T) {
self.validator = builder()
}
func validate(_ value: [E]) throws {
for item in value {
try validator.validate(item)
}
}
}

View File

@@ -0,0 +1,159 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import SQLKit
import Validations
extension DatabaseClient.ComponentLosses: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
},
get: { id in
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
// try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
)
}
}
extension ComponentPressureLoss.Create {
func toModel() -> ComponentLossModel {
return .init(name: name, value: value, projectID: projectID)
}
}
extension ComponentPressureLoss {
struct Migrate: AsyncMigration {
let name = "CreateComponentLoss"
func prepare(on database: any Database) async throws {
try await database.schema(ComponentLossModel.schema)
.id()
.field("name", .string, .required)
.field("value", .double, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
// .unique(on: "projectID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(ComponentLossModel.schema).delete()
}
}
}
final class ComponentLossModel: Model, @unchecked Sendable {
static let schema = "component_loss"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "value")
var value: Double
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
value: Double,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.value = value
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> ComponentPressureLoss {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
value: value,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: ComponentPressureLoss.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let value = updates.value, value != self.value {
self.value = value
}
}
}
extension ComponentLossModel: Validatable {
var body: some Validation<ComponentLossModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.value) {
Double.greaterThan(0.0)
Double.lessThanOrEquals(1.0)
}
.errorLabel("Value", inline: true)
}
}
}

View File

@@ -0,0 +1,179 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectId in
guard
let model = try await EquipmentModel.query(on: database)
.filter("projectID", .equal, projectId)
.first()
else {
return nil
}
return try model.toDTO()
},
get: { id in
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
)
}
}
extension EquipmentInfo.Create {
func toModel() -> EquipmentModel {
return .init(
staticPressure: staticPressure,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
projectID: projectID
)
}
}
extension EquipmentInfo {
struct Migrate: AsyncMigration {
let name = "CreateEquipment"
func prepare(on database: any Database) async throws {
try await database.schema(EquipmentModel.schema)
.id()
.field("staticPressure", .double, .required)
.field("heatingCFM", .int16, .required)
.field("coolingCFM", .int16, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EquipmentModel.schema).delete()
}
}
}
final class EquipmentModel: Model, @unchecked Sendable {
static let schema = "equipment"
@ID(key: .id)
var id: UUID?
@Field(key: "staticPressure")
var staticPressure: Double
@Field(key: "heatingCFM")
var heatingCFM: Int
@Field(key: "coolingCFM")
var coolingCFM: Int
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
staticPressure: Double,
heatingCFM: Int,
coolingCFM: Int,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> EquipmentInfo {
try .init(
id: requireID(),
projectID: $project.id,
staticPressure: staticPressure,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EquipmentInfo.Update) {
if let staticPressure = updates.staticPressure {
self.staticPressure = staticPressure
}
if let heatingCFM = updates.heatingCFM {
self.heatingCFM = heatingCFM
}
if let coolingCFM = updates.coolingCFM {
self.coolingCFM = coolingCFM
}
}
}
extension EquipmentModel: Validatable {
var body: some Validation<EquipmentModel> {
Validator.accumulating {
Validator.validate(\.staticPressure) {
Double.greaterThan(0.0)
Double.lessThan(1.0)
}
.errorLabel("Static Pressure", inline: true)
Validator.validate(\.heatingCFM, with: .greaterThan(0))
.errorLabel("Heating CFM", inline: true)
Validator.validate(\.coolingCFM, with: .greaterThan(0))
.errorLabel("Cooling CFM", inline: true)
}
}
}

View File

@@ -0,0 +1,231 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
},
fetchMax: { projectID in
let effectiveLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
return .init(
supply: effectiveLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first,
return: effectiveLengths.filter({ $0.type == .return })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first
)
},
get: { id in
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
throw NotFoundError()
}
try model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
)
}
}
extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel {
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
return try .init(
name: name,
type: type.rawValue,
straightLengths: straightLengths,
groups: JSONEncoder().encode(groups),
projectID: projectID
)
}
}
extension EquivalentLength {
struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength"
func prepare(on database: any Database) async throws {
try await database.schema(EffectiveLengthModel.schema)
.id()
.field("name", .string, .required)
.field("type", .string, .required)
.field("straightLengths", .array(of: .int))
.field("groups", .data)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name", "type")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EffectiveLengthModel.schema).delete()
}
}
}
// TODO: Add total effective length field so that we can lookup / compare which one is
// the longest for a given project.
final class EffectiveLengthModel: Model, @unchecked Sendable {
static let schema = "effective_length"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "type")
var type: String
@Field(key: "straightLengths")
var straightLengths: [Int]
@Field(key: "groups")
var groups: Data
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
type: String,
straightLengths: [Int],
groups: Data,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> EquivalentLength {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
type: .init(rawValue: type)!,
straightLengths: straightLengths,
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
self.straightLengths = straightLengths
}
if let groups = updates.groups {
if groups.count > 0 {
try [EquivalentLength.FittingGroup].validator().validate(groups)
}
self.groups = try JSONEncoder().encode(groups)
}
}
}
extension EffectiveLengthModel: Validatable {
var body: some Validation<EffectiveLengthModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(
\.straightLengths,
with: [Int].empty().or(
ForEachValidator {
Int.greaterThan(0)
})
)
.errorLabel("Straight Lengths", inline: true)
}
}
}
extension EquivalentLength.FittingGroup: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.group) {
Int.greaterThanOrEquals(1)
Int.lessThanOrEquals(12)
}
.errorLabel("Group", inline: true)
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
.errorLabel("Letter", inline: true)
Validator.validate(\.value, with: .greaterThan(0))
.errorLabel("Value", inline: true)
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
.errorLabel("Quantity", inline: true)
}
}
}

View File

@@ -0,0 +1,22 @@
import Dependencies
import ManualDCore
extension DatabaseClient.Migrations: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
all: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EquivalentLength.Migrate(),
TrunkSize.Migrate(),
]
}
)
}

View File

@@ -0,0 +1,10 @@
import Fluent
import Validations
extension Model where Self: Validations.Validatable {
func validateAndSave(on database: any Database) async throws {
try self.validate()
try await self.save(on: database)
}
}

View File

@@ -0,0 +1,311 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { userID, request in
let model = request.toModel(userID: userID)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
detail: { id in
let model = try await ProjectModel.fetchDetail(for: id, on: database)
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
getCompletedSteps: { id in
let model = try await ProjectModel.fetchDetail(for: id, on: database)
var equivalentLengthsCompleted = false
if model.equivalentLengths.filter({ $0.type == "supply" }).first != nil,
model.equivalentLengths.filter({ $0.type == "return" }).first != nil
{
equivalentLengthsCompleted = true
}
return .init(
equipmentInfo: model.equipment != nil,
rooms: model.rooms.count > 0,
equivalentLength: equivalentLengthsCompleted,
frictionRate: model.componentLosses.count > 0
)
},
getSensibleHeatRatio: { id in
guard
let model = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return model.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
.sort(\.$createdAt, .descending)
.with(\.$user)
.filter(\.$user.$id == userID)
.paginate(request)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
)
}
}
extension Project.Create {
func toModel(userID: User.ID) -> ProjectModel {
return .init(
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
userID: userID
)
}
}
extension Project {
struct Migrate: AsyncMigration {
let name = "CreateProject"
func prepare(on database: any Database) async throws {
try await database.schema(ProjectModel.schema)
.id()
.field("name", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("sensibleHeatRatio", .double)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
.unique(on: "userID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(ProjectModel.schema).delete()
}
}
}
// The Database model.
final class ProjectModel: Model, @unchecked Sendable {
static let schema = "project"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "sensibleHeatRatio")
var sensibleHeatRatio: Double?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@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
init() {}
init(
id: UUID? = nil,
name: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
userID: User.ID,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
$user.id = userID
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toDTO() throws -> Project {
try .init(
id: requireID(),
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
sensibleHeatRatio: sensibleHeatRatio,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Project.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let sensibleHeatRatio = updates.sensibleHeatRatio,
sensibleHeatRatio != self.sensibleHeatRatio
{
self.sensibleHeatRatio = sensibleHeatRatio
}
}
/// Returns a ``ProjectModel`` with all the relations eagerly loaded.
static func fetchDetail(
for projectID: Project.ID,
on database: any Database
) async throws -> ProjectModel {
guard
let model =
try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == projectID)
.first()
else {
throw NotFoundError()
}
return model
}
}
extension ProjectModel: Validatable {
var body: some Validation<ProjectModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
Validator.validate(\.sensibleHeatRatio) {
Validator {
Double.greaterThan(0)
Double.lessThanOrEquals(1.0)
}
.optional()
}
.errorLabel("Sensible Heat Ratio", inline: true)
}
}
}

View File

@@ -0,0 +1,396 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { projectID, request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
},
createMany: { projectID, rooms in
try await RoomModel.createMany(projectID: projectID, rooms: rooms, on: database)
},
createFromCSV: { projectID, rows in
database.logger.debug("\nCreate From CSV rows: \(rows)\n")
// Filter out rows that delegate their airflow / load to another room,
// these need to be created last.
let rowsThatDelegate = rows.filter({
$0.delegatedToName != nil && $0.delegatedToName != ""
})
// Filter out the rest of the rooms that don't delegate their airflow / loads.
let initialRooms = rows.filter({
$0.delegatedToName == nil || $0.delegatedToName == ""
})
.map(\.createModel)
database.logger.debug("\nInitial rows: \(initialRooms)\n")
let initialCreated = try await RoomModel.createMany(
projectID: projectID,
rooms: initialRooms,
on: database
)
database.logger.debug("\nInitially created rows: \(initialCreated)\n")
let roomsThatDelegateModels = try rowsThatDelegate.reduce(into: [Room.Create]()) {
array, row in
database.logger.debug("\n\(row.name), delegating to: \(row.delegatedToName!)\n")
guard let created = initialCreated.first(where: { $0.name == row.delegatedToName }) else {
database.logger.debug(
"\nUnable to find created room with name: \(row.delegatedToName!)\n"
)
throw NotFoundError()
}
array.append(
Room.Create.init(
name: row.name,
level: row.level,
heatingLoad: row.heatingLoad,
coolingTotal: row.coolingTotal,
coolingSensible: row.coolingSensible,
registerCount: 0,
delegatedTo: created.id
)
)
}
return try await RoomModel.createMany(
projectID: projectID,
rooms: roomsThatDelegateModels,
on: database
) + initialCreated
},
delete: { id in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
deleteRectangularSize: { roomID, rectangularDuctID in
guard let model = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID
}
if model.rectangularSizes?.count == 0 {
model.rectangularSizes = nil
}
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
},
get: { id in
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
},
fetch: { projectID in
try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.sort(\.$name, .ascending)
.all()
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
},
updateRectangularSize: { id, size in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
var rectangularSizes = model.rectangularSizes ?? []
rectangularSizes.removeAll {
$0.id == size.id
}
rectangularSizes.append(size)
model.rectangularSizes = rectangularSizes
try await model.save(on: database)
return try model.toDTO()
}
)
}
}
extension Room.CSV.Row {
fileprivate var createModel: Room.Create {
assert(delegatedToName == nil || delegatedToName == "")
return .init(
name: name,
level: level,
heatingLoad: heatingLoad,
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
delegatedTo: nil
)
}
}
extension RoomModel {
fileprivate static func createMany(
projectID: Project.ID,
rooms: [Room.Create],
on database: any Database
) async throws -> [Room] {
try await rooms.asyncMap { request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
}
}
}
extension Room.Create {
func toModel(projectID: Project.ID) throws -> RoomModel {
var registerCount = registerCount
// Set register count appropriately when delegatedTo is set / changes.
if delegatedTo != nil {
registerCount = 0
} else if registerCount == 0 {
registerCount = 1
}
return .init(
name: name,
level: level?.rawValue,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
registerCount: registerCount,
delegetedToID: delegatedTo,
projectID: projectID
)
}
}
extension Room {
struct Migrate: AsyncMigration {
let name = "CreateRoom"
func prepare(on database: any Database) async throws {
try await database.schema(RoomModel.schema)
.id()
.field("name", .string, .required)
.field("level", .int8)
.field("heatingLoad", .double, .required)
.field("coolingLoad", .dictionary, .required)
.field("registerCount", .int8, .required)
.field("delegatedToID", .uuid, .references(RoomModel.schema, "id"))
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(RoomModel.schema).delete()
}
}
}
final class RoomModel: Model, @unchecked Sendable, Validatable {
static let schema = "room"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "level")
var level: Int?
@Field(key: "heatingLoad")
var heatingLoad: Double
@Field(key: "coolingLoad")
var coolingLoad: Room.CoolingLoad
@Field(key: "registerCount")
var registerCount: Int
@OptionalParent(key: "delegatedToID")
var room: RoomModel?
@Field(key: "rectangularSizes")
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@Parent(key: "projectID")
var project: ProjectModel
init() {}
init(
id: UUID? = nil,
name: String,
level: Int? = nil,
heatingLoad: Double,
coolingLoad: Room.CoolingLoad,
registerCount: Int,
delegetedToID: UUID? = nil,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
) {
self.id = id
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.registerCount = registerCount
$room.id = delegetedToID
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
}
func toDTO() throws -> Room {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
level: level.map(Room.Level.init(rawValue:)),
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
registerCount: registerCount,
delegatedTo: $room.id,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Room.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let level = updates.level?.rawValue, level != self.level {
self.level = level
}
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad {
self.coolingLoad = coolingLoad
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
}
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
self.rectangularSizes = rectangularSizes
}
}
var body: some Validation<RoomModel> {
Validator.accumulating {
Validator.validate(\.name, with: .notEmpty())
.errorLabel("Name", inline: true)
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
.errorLabel("Heating Load", inline: true)
Validator.validate(\.coolingLoad)
.errorLabel("Cooling Load", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals($room.id == nil ? 1 : 0))
.errorLabel("Register Count", inline: true)
Validator.validate(\.rectangularSizes)
}
}
func validateAndSave(on database: Database) async throws {
try self.validate()
if let delegateTo = $room.id {
guard
let parent =
try await RoomModel
.query(on: database)
.with(\.$room)
.filter(\.$id == delegateTo)
.first()
else {
throw ValidationError("Can not find room: \(delegateTo), to delegate airflow to.")
}
guard parent.$room.id == nil else {
throw ValidationError(
"""
Attempting to delegate to: \(parent.name), that delegates to: \(parent.$room.name)
Unable to delegate airflow to a room that already delegates it's airflow.
"""
)
}
}
try await save(on: database)
}
}
extension Room.CoolingLoad: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
// Ensure that at least one of the values is not nil.
Validator.oneOf {
Validator.validate(\.total, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
Validator.validate(\.sensible, with: .notNil())
.errorLabel("Total or Sensible", inline: true)
}
Validator.validate(\.total, with: Double.greaterThan(0).optional())
Validator.validate(\.sensible, with: Double.greaterThan(0).optional())
}
}
}
extension Room.RectangularSize: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.register, with: Int.greaterThanOrEquals(1).optional())
.errorLabel("Register", inline: true)
Validator.validate(\.height, with: Int.greaterThanOrEquals(1))
.errorLabel("Height", inline: true)
}
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
extension Sequence {
// Taken from: https://forums.swift.org/t/are-there-any-kind-of-asyncmap-method-for-a-normal-sequence/77354/7
func asyncMap<Result: Sendable>(
_ transform: @escaping @Sendable (Element) async throws -> Result
) async rethrows -> [Result] where Element: Sendable {
try await withThrowingTaskGroup(of: (Int, Result).self) { group in
var i = 0
var iterator = self.makeIterator()
var results = [Result?]()
results.reserveCapacity(underestimatedCount)
func submitTask() throws {
try Task.checkCancellation()
if let element = iterator.next() {
results.append(nil)
group.addTask { [i] in try await (i, transform(element)) }
i += 1
}
}
// Add initial tasks
for _ in 0..<ProcessInfo.processInfo.processorCount {
try submitTask()
}
// Submit more tasks as results complete
while let (index, result) = try await group.next() {
results[index] = result
try submitTask()
}
return results.compactMap { $0 }
}
}
}

View File

@@ -0,0 +1,335 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
// try request.validate()
let trunk = request.toModel()
var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.validateAndSave(on: database)
for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
let model = try TrunkRoomModel(
trunkID: trunk.requireID(),
roomID: room.requireID(),
registers: registers,
type: request.type
)
try await model.validateAndSave(on: database)
roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
}
return try .init(
id: trunk.requireID(),
projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!,
rooms: roomProxies,
height: trunk.height,
name: trunk.name
)
},
delete: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$project.$id == projectID)
.all()
.toDTO()
},
get: { id in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
return nil
}
return try model.toDTO()
},
update: { id, updates in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// try updates.validate()
try await model.applyUpdates(updates, on: database)
return try model.toDTO()
}
)
}
}
extension TrunkSize.Create {
func toModel() -> TrunkModel {
.init(
projectID: projectID,
type: type,
height: height,
name: name
)
}
}
extension TrunkSize {
struct Migrate: AsyncMigration {
let name = "CreateTrunkSize"
func prepare(on database: any Database) async throws {
try await database.schema(TrunkModel.schema)
.id()
.field("height", .int8)
.field("name", .string)
.field("type", .string, .required)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.create()
try await database.schema(TrunkRoomModel.schema)
.id()
.field("registers", .array(of: .int), .required)
.field("type", .string, .required)
.field(
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
)
.field(
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
)
.unique(on: "trunkID", "roomID", "type")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(TrunkRoomModel.schema).delete()
try await database.schema(TrunkModel.schema).delete()
}
}
}
// Pivot table for associating rooms and trunks.
final class TrunkRoomModel: Model, @unchecked Sendable {
static let schema = "room+trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "trunkID")
var trunk: TrunkModel
@Parent(key: "roomID")
var room: RoomModel
@Field(key: "registers")
var registers: [Int]
@Field(key: "type")
var type: String
init() {}
init(
id: UUID? = nil,
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int],
type: TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
self.type = type.rawValue
}
func toDTO() throws -> TrunkSize.RoomProxy {
return .init(
room: try room.toDTO(),
registers: registers
)
}
}
extension TrunkRoomModel: Validatable {
var body: some Validation<TrunkRoomModel> {
Validator.validate(\.registers) {
[Int].notEmpty()
ForEachValidator {
Int.greaterThanOrEquals(1)
}
}
}
}
final class TrunkModel: Model, @unchecked Sendable {
static let schema = "trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "projectID")
var project: ProjectModel
@OptionalField(key: "height")
var height: Int?
@Field(key: "type")
var type: String
@OptionalField(key: "name")
var name: String?
@Children(for: \.$trunk)
var rooms: [TrunkRoomModel]
init() {}
init(
id: UUID? = nil,
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil
) {
self.id = id
$project.id = projectID
self.height = height
self.type = type.rawValue
self.name = name
}
func toDTO() throws -> TrunkSize {
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
$0.append(try $1.toDTO())
}
return try .init(
id: requireID(),
projectID: $project.id,
type: .init(rawValue: type)!,
rooms: rooms,
height: height,
name: name
)
}
func applyUpdates(
_ updates: TrunkSize.Update,
on database: any Database
) async throws {
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let height = updates.height, height != self.height {
self.height = height
}
if let name = updates.name, name != self.name {
self.name = name
}
if hasChanges {
try await self.validateAndSave(on: database)
}
guard let updateRooms = updates.rooms else {
return
}
// Update rooms.
let rooms = try await TrunkRoomModel.query(on: database)
.with(\.$room)
.filter(\.$trunk.$id == requireID())
.all()
for (roomID, registers) in updateRooms {
if let currRoom = rooms.first(where: { $0.$room.id == roomID }) {
database.logger.debug("CURRENT ROOM: \(currRoom.room.name)")
if registers != currRoom.registers {
database.logger.debug("Updating registers for: \(currRoom.room.name)")
currRoom.registers = registers
}
if currRoom.hasChanges {
try await currRoom.validateAndSave(on: database)
}
} else {
database.logger.debug("CREATING NEW TrunkRoomModel")
let newModel = try TrunkRoomModel(
trunkID: requireID(),
roomID: roomID,
registers: registers,
type: .init(rawValue: type)!
)
try await newModel.save(on: database)
}
}
let roomsToDelete = rooms.filter {
!updateRooms.keys.contains($0.$room.id)
}
for room in roomsToDelete {
try await room.delete(on: database)
}
database.logger.debug("DONE WITH UPDATES")
}
}
extension TrunkModel: Validatable {
var body: some Validation<TrunkModel> {
Validator.accumulating {
Validator.validate(\.height, with: Int.greaterThan(0).optional())
.errorLabel("Height", inline: true)
Validator.validate(\.name, with: String.notEmpty().optional())
.errorLabel("Name", inline: true)
}
}
}
extension Array where Element == TrunkModel {
func toDTO() throws -> [TrunkSize] {
return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO())
}
}
}

View File

@@ -0,0 +1,20 @@
import ManualDCore
import Validations
// Declaring this in seperate file because some Vapor imports
// have same name's and this was easiest solution.
extension User.Create: Validatable {
public var body: some Validation<Self> {
Validator.accumulating {
Validator.validate(\.email, with: .email())
.errorLabel("Email", inline: true)
Validator.validate(\.password.count, with: .greaterThanOrEquals(8))
.errorLabel("Password Count", inline: true)
Validator.validate(\.confirmPassword, with: .equals(password))
.mapError(ValidationError("Confirm password does not match."))
.errorLabel("Confirm Password", inline: true)
}
}
}

View File

@@ -0,0 +1,234 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Validations
extension DatabaseClient.UserProfiles: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
let model = profile.toModel()
try await model.validateAndSave(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { userID in
try await UserProfileModel.query(on: database)
.with(\.$user)
.filter(\.$user.$id == userID)
.first()
.map { try $0.toDTO() }
},
get: { id in
try await UserProfileModel.find(id, on: database)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.validateAndSave(on: database)
}
return try model.toDTO()
}
)
}
}
extension User.Profile.Create {
func toModel() -> UserProfileModel {
.init(
userID: userID,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: theme
)
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
let name = "Create UserProfile"
func prepare(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema)
.id()
.field("firstName", .string, .required)
.field("lastName", .string, .required)
.field("companyName", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("theme", .string)
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "userID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema).delete()
}
}
}
final class UserProfileModel: Model, @unchecked Sendable {
static let schema = "user_profile"
@ID(key: .id)
var id: UUID?
@Parent(key: "userID")
var user: UserModel
@Field(key: "firstName")
var firstName: String
@Field(key: "lastName")
var lastName: String
@Field(key: "companyName")
var companyName: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "theme")
var theme: String?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.id = id
$user.id = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme?.rawValue
}
func toDTO() throws -> User.Profile {
try .init(
id: requireID(),
userID: $user.id,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: self.theme.flatMap(Theme.init),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: User.Profile.Update) {
if let firstName = updates.firstName, firstName != self.firstName {
self.firstName = firstName
}
if let lastName = updates.lastName, lastName != self.lastName {
self.lastName = lastName
}
if let companyName = updates.companyName, companyName != self.companyName {
self.companyName = companyName
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let theme = updates.theme, theme.rawValue != self.theme {
self.theme = theme.rawValue
}
}
}
extension UserProfileModel: Validatable {
var body: some Validation<UserProfileModel> {
Validator.accumulating {
Validator.validate(\.firstName, with: .notEmpty())
.errorLabel("First Name", inline: true)
Validator.validate(\.lastName, with: .notEmpty())
.errorLabel("Last Name", inline: true)
Validator.validate(\.companyName, with: .notEmpty())
.errorLabel("Company", inline: true)
Validator.validate(\.streetAddress, with: .notEmpty())
.errorLabel("Address", inline: true)
Validator.validate(\.city, with: .notEmpty())
.errorLabel("City", inline: true)
Validator.validate(\.state, with: .notEmpty())
.errorLabel("State", inline: true)
Validator.validate(\.zipCode, with: .notEmpty())
.errorLabel("Zip", inline: true)
}
}
}

View File

@@ -0,0 +1,267 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
import Vapor
extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
try request.validate()
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
get: { id in
try await UserModel.find(id, on: database).map { try $0.toDTO() }
},
login: { request in
guard
let user = try await UserModel.query(on: database)
.with(\.$token)
.filter(\UserModel.$email == request.email)
.first()
else {
throw NotFoundError()
}
// Verify the password matches the user's hashed password.
guard try user.verifyPassword(request.password) else {
throw Abort(.unauthorized)
}
let token: User.Token
// Check if there's a user token
if let userToken = user.token {
token = try userToken.toDTO()
} else {
// generate a new token
let tokenModel = try user.generateToken()
try await tokenModel.save(on: database)
token = try tokenModel.toDTO()
}
return token
},
logout: { tokenID in
guard let token = try await UserTokenModel.find(tokenID, on: database) else { return }
try await token.delete(on: database)
}
// ,
// token: { id in
// }
)
}
}
extension User {
struct Migrate: AsyncMigration {
let name = "CreateUser"
func prepare(on database: any Database) async throws {
try await database.schema(UserModel.schema)
.id()
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "email")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserModel.schema).delete()
}
}
}
extension User.Token {
struct Migrate: AsyncMigration {
let name = "CreateUserToken"
func prepare(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema)
.id()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema).delete()
}
}
}
extension User {
static func hashPassword(_ password: String) throws -> String {
try Bcrypt.hash(password, cost: 12)
}
}
extension User.Create {
func toModel() throws -> UserModel {
return try .init(email: email, passwordHash: User.hashPassword(password))
}
}
final class UserModel: Model, @unchecked Sendable {
static let schema = "user"
@ID(key: .id)
var id: UUID?
@Field(key: "email")
var email: String
@Field(key: "password_hash")
var passwordHash: String
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@OptionalChild(for: \.$user)
var token: UserTokenModel?
init() {}
init(
id: UUID? = nil,
email: String,
passwordHash: String
) {
self.id = id
self.email = email
self.passwordHash = passwordHash
}
func toDTO() throws -> User {
try .init(
id: requireID(),
email: email,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func generateToken() throws -> UserTokenModel {
try .init(
value: [UInt8].random(count: 16).base64,
userID: requireID()
)
}
func verifyPassword(_ password: String) throws -> Bool {
try Bcrypt.verify(password, created: passwordHash)
}
}
final class UserTokenModel: Model, Codable, @unchecked Sendable {
static let schema = "user_token"
@ID(key: .id)
var id: UUID?
@Field(key: "value")
var value: String
@Parent(key: "user_id")
var user: UserModel
init() {}
init(id: UUID? = nil, value: String, userID: UserModel.IDValue) {
self.id = id
self.value = value
$user.id = userID
}
func toDTO() throws -> User.Token {
try .init(id: requireID(), userID: $user.id, value: value)
}
}
// MARK: - Authentication
extension User: Authenticatable {}
extension User: SessionAuthenticatable {
public var sessionID: String { email }
}
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$email == basic.username)
.first(),
try user.verifyPassword(basic.password)
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}
public struct UserTokenAuthenticator: AsyncBearerAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
guard
let token = try await UserTokenModel.query(on: request.db)
.filter(\UserTokenModel.$value == bearer.token)
.with(\UserTokenModel.$user)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(token.user.toDTO())
}
}
public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$email == sessionID)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}

View File

@@ -1,161 +0,0 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var get: @Sendable (Project.ID) async throws -> Project?
}
}
extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Projects {
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
get: { id in
ProjectModel.find(id, on: database).map { try $0.toDTO() }
}
)
}
}
extension Project.Create {
func toModel() throws -> ProjectModel {
try validate()
return .init(
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode
)
}
func validate() throws(ValidationError) {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
}
extension Project {
struct Migrate: AsyncMigration {
let name = "CreateProject"
func prepare(on database: any Database) async throws {
try await database.schema(ProjectModel.schema)
.id()
.field("name", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "name")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(ProjectModel.schema).delete()
}
}
}
// The Database model.
final class ProjectModel: Model, @unchecked Sendable {
static let schema = "project"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
name: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
createdAt: Date? = nil,
updatedAt: Date? = nil
) {
self.id = id
self.name = name
self.streetAddress = streetAddress
self.city = city
self.city = city
self.state = state
self.zipCode = zipCode
self.createdAt = createdAt
self.updatedAt = updatedAt
}
func toDTO() throws -> Project {
try .init(
id: requireID(),
name: name,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
}

View File

@@ -0,0 +1,100 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var environment: EnvVars {
get { self[EnvVars.self] }
set { self[EnvVars.self] = newValue }
}
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
/// The postgres hostname, used for production database connection.
public let postgresHostname: String?
/// The postgres username, used for production database connection.
public let postgresUsername: String?
/// The postgres password, used for production database connection.
public let postgresPassword: String?
/// The postgres database, used for production database connection.
public let postgresDatabase: String?
/// The path to the sqlite database, used for development database connection.
public let sqlitePath: String?
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint",
postgresHostname: String? = "localhost",
postgresUsername: String? = "vapor",
postgresPassword: String? = "super-secret",
postgresDatabase: String? = "vapor",
sqlitePath: String? = "db.sqlite"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
self.postgresHostname = postgresHostname
self.postgresUsername = postgresUsername
self.postgresPassword = postgresPassword
self.postgresDatabase = postgresDatabase
self.sqlitePath = sqlitePath
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
case postgresHostname = "POSTGRES_HOSTNAME"
case postgresUsername = "POSTGRES_USER"
case postgresPassword = "POSTGRES_PASSWORD"
case postgresDatabase = "POSTGRES_DB"
case sqlitePath = "SQLITE_PATH"
}
public static func live(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Self {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(env, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
}
}
extension EnvVars: TestDependencyKey {
public static let testValue = Self()
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -0,0 +1,59 @@
import Dependencies
import DependenciesMacros
import Foundation
import Vapor
extension DependencyValues {
/// Dependency used for file operations.
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
@DependencyClient
public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void
/// Write contents to a file.
///
/// > Warning: This will overwrite a file if it exists.
public var writeFile: @Sendable (_ contents: String, _ path: String) async throws -> Void
/// Remove a file.
public var removeFile: @Sendable (_ path: String) async throws -> Void
/// Stream a file.
public var streamFile:
@Sendable (_ path: String, @escaping OnCompleteHandler) async throws -> Response
/// Stream a file at the given path.
///
/// - Paramters:
/// - path: The path to the file to stream.
/// - onComplete: Completion handler to run when done streaming the file.
public func streamFile(
at path: String,
onComplete: @escaping OnCompleteHandler = {}
) async throws -> Response {
try await streamFile(path, onComplete)
}
}
extension FileClient: TestDependencyKey {
public static let testValue = Self()
public static func live(fileIO: FileIO) -> Self {
.init(
writeFile: { contents, path in
try await fileIO.writeFile(ByteBuffer(string: contents), at: path)
},
removeFile: { path in
try FileManager.default.removeItem(atPath: path)
},
streamFile: { path, onComplete in
try await fileIO.asyncStreamFile(at: path) { _ in
try await onComplete()
}
}
)
}
}

View File

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

View File

@@ -1,10 +1,6 @@
import Foundation
import ManualDCore
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength }
@@ -19,6 +15,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
@@ -54,16 +52,16 @@ func roundSize(_ size: Double) throws -> Int {
}
}
func velocity(cfm: Int, roundSize: Int) -> Int {
let cfm = Double(cfm)
func velocity(cfm: ManualDClient.CFM, roundSize: Int) -> Int {
let cfm = Double(cfm.rawValue)
let roundSize = Double(roundSize)
let velocity = cfm / (pow(roundSize / 24, 2) * 3.14)
return Int(round(velocity))
}
func flexSize(_ request: ManualDClient.DuctSizeRequest) throws -> Int {
let cfm = pow(Double(request.designCFM), 0.4)
let fr = pow(request.frictionRate / 1.76, 0.2)
func flexSize(_ cfm: ManualDClient.CFM, _ frictionRate: Double) throws -> Int {
let cfm = pow(Double(cfm.rawValue), 0.4)
let fr = pow(frictionRate / 1.76, 0.2)
let size = 0.55 * (cfm / fr)
return try roundSize(size)
}

View File

@@ -0,0 +1,141 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
import Tagged
extension DependencyValues {
/// Dependency that performs manual-d duct sizing calculations.
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
/// Performs manual-d duct sizing calculations.
///
///
@DependencyClient
public struct ManualDClient: Sendable {
/// Calculates the duct size for the given cfm and friction rate.
public var ductSize: @Sendable (CFM, DesignFrictionRate) async throws -> DuctSize
/// Calculates the design friction rate for the given request.
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
public var rectangularSize: @Sendable (RoundSize, Height) async throws -> RectangularSize
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Int,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: designCFM), .init(rawValue: designFrictionRate))
}
/// Calculates the duct size for the given cfm and friction rate.
///
/// - Paramaters:
/// - designCFM: The design cfm for the duct.
/// - designFrictionRate: The design friction rate for the system.
public func ductSize(
cfm designCFM: Double,
frictionRate designFrictionRate: Double
) async throws -> DuctSize {
try await ductSize(.init(rawValue: Int(designCFM)), .init(rawValue: designFrictionRate))
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: RoundSize,
height: Height
) async throws -> RectangularSize {
try await rectangularSize(roundSize, height)
}
/// Calculates the equivalent rectangular size for the given round duct and rectangular height.
///
/// - Paramaters:
/// - roundSize: The round duct size.
/// - height: The rectangular height of the duct.
public func rectangularSize(
round roundSize: Int,
height: Int
) async throws -> RectangularSize {
try await rectangularSize(.init(rawValue: roundSize), .init(rawValue: height))
}
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension ManualDClient {
/// A name space for tags used by the ManualDClient.
public enum Tag {
public enum CFM {}
public enum DesignFrictionRate {}
public enum Height {}
public enum Round {}
}
public typealias CFM = Tagged<Tag.CFM, Int>
public typealias DesignFrictionRate = Tagged<Tag.DesignFrictionRate, Double>
public typealias Height = Tagged<Tag.Height, Int>
public typealias RoundSize = Tagged<Tag.Round, Int>
public struct DuctSize: Codable, Equatable, Sendable {
public let calculatedSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.calculatedSize = calculatedSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEquivalentLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEquivalentLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEquivalentLength = totalEquivalentLength
}
}
public struct RectangularSize: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

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

View File

@@ -1,133 +0,0 @@
import Dependencies
import DependenciesMacros
import ManualDCore
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRateResponse
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension DependencyValues {
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: ComponentPressureLosses
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: ComponentPressureLosses,
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

@@ -0,0 +1,184 @@
import Dependencies
import Foundation
/// Represents component pressure losses used in the friction rate worksheet.
///
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
/// need to be overcome by the system fan.
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let name: String
public let value: Double
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
value: Double,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.value = value
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension ComponentPressureLoss {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let value: Double
public init(
projectID: Project.ID,
name: String,
value: Double,
) {
self.projectID = projectID
self.name = name
self.value = value
}
/// Commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] {
[
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
.init(projectID: projectID, name: "return-grille", value: 0.03),
.init(projectID: projectID, name: "balancing-damper", value: 0.03),
]
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let value: Double?
public init(
name: String? = nil,
value: Double? = nil
) {
self.name = name
self.value = value
}
}
}
extension Array where Element == ComponentPressureLoss {
public var total: Double {
reduce(into: 0) { $0 += $1.value }
}
}
#if DEBUG
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(
id: UUID(0),
projectID: UUID(0),
name: "evaporator-coil",
value: 0.2,
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(1),
projectID: UUID(0),
name: "filter",
value: 0.1,
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(2),
projectID: UUID(0),
name: "supply-outlet",
value: 0.03,
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(3),
projectID: UUID(0),
name: "return-grille",
value: 0.03,
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(4),
projectID: UUID(0),
name: "balance-damper",
value: 0.03,
createdAt: Date(),
updatedAt: Date()
),
]
}
}
#endif

View File

@@ -1,17 +0,0 @@
import Foundation
public typealias ComponentPressureLosses = [String: Double]
#if DEBUG
extension ComponentPressureLosses {
public static var mock: Self {
[
"evaporator-coil": 0.2,
"filter": 0.1,
"supply-outlet": 0.03,
"return-grille": 0.03,
"balancing-damper": 0.03,
]
}
}
#endif

View File

@@ -1,13 +1,14 @@
import Foundation
// import Foundation
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double
public let sensible: Double
public var latent: Double { total - sensible }
public var shr: Double { sensible / total }
public init(total: Double, sensible: Double) {
self.total = total
self.sensible = sensible
}
}
//
// public struct CoolingLoad: Codable, Equatable, Sendable {
// public let total: Double
// public let sensible: Double
// public var latent: Double { total - sensible }
// public var shr: Double { sensible / total }
//
// public init(total: Double, sensible: Double) {
// self.total = total
// self.sensible = sensible
// }
// }

View File

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

View File

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

View File

@@ -1,17 +1,101 @@
import Dependencies
import Foundation
public struct EquipmentInfo: Codable, Equatable, Sendable {
/// Represents the equipment information for a project.
///
/// This is used in the friction rate worksheet and sizing ducts. It holds on to items
/// such as the target static pressure, cooling CFM, and heating CFM for the project.
public struct EquipmentInfo: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let staticPressure: Double
public let heatingCFM: Int
public let coolingCFM: Int
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
staticPressure: Double = 0.5,
heatingCFM: Int,
coolingCFM: Int
coolingCFM: Int,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EquipmentInfo {
// TODO: Remove projectID and use dependency to lookup current project ??
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let staticPressure: Double
public let heatingCFM: Int
public let coolingCFM: Int
public init(
projectID: Project.ID,
staticPressure: Double = 0.5,
heatingCFM: Int,
coolingCFM: Int
) {
self.projectID = projectID
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
}
}
public struct Update: Codable, Equatable, Sendable {
public let staticPressure: Double?
public let heatingCFM: Int?
public let coolingCFM: Int?
public init(
staticPressure: Double? = nil,
heatingCFM: Int? = nil,
coolingCFM: Int? = nil
) {
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
}
}
}
#if DEBUG
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),
heatingCFM: 1000,
coolingCFM: 1000,
createdAt: Date(),
updatedAt: Date()
)
}
#endif

View File

@@ -0,0 +1,239 @@
import Dependencies
import Foundation
/// Represents the equivalent length of a single duct path.
///
/// These consist of both straight lengths of duct / trunks, as well as the
/// equivalent length of duct fittings. They are used to determine the worst
/// case total equivalent length of duct that the system fan has to move air
/// through.
///
/// There can be many equivalent lengths saved for a project, however the only
/// ones that matter in most calculations are the longest supply path and the
/// the longest return path.
///
/// It is required that project has at least one equivalent length saved for
/// the supply and one saved for return, otherwise duct sizes can not be calculated.
public struct EquivalentLength: Codable, Equatable, Identifiable, Sendable {
/// The id of the equivalent length.
public let id: UUID
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
/// When this equivalent length was created in the database.
public let createdAt: Date
/// When this equivalent length was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EquivalentLength {
/// Represents the data needed to create a new ``EquivalentLength`` in the database.
public struct Create: Codable, Equatable, Sendable {
/// The project that this equivalent length is associated with.
public let projectID: Project.ID
/// A unique name / label for this equivalent length.
public let name: String
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]
public init(
projectID: Project.ID,
name: String,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EquivalentLength.FittingGroup]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the data needed to update an ``EquivalentLength`` in the database.
///
/// Only the supplied fields are updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name / label for this equivalent length.
public let name: String?
/// The type (supply or return) of the equivalent length.
public let type: EffectiveLengthType?
/// The straight lengths of duct for this equivalent length.
public let straightLengths: [Int]?
/// The fitting groups associated with this equivalent length.
public let groups: [FittingGroup]?
public init(
name: String? = nil,
type: EquivalentLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EquivalentLength.FittingGroup]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
/// Represents the type of equivalent length, either supply or return.
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
/// Represents a Manual-D fitting group.
///
/// These are defined by Manual-D and convert different types of fittings into
/// an equivalent length of straight duct.
public struct FittingGroup: Codable, Equatable, Sendable {
/// The fitting group number.
public let group: Int
/// The fitting group letter.
public let letter: String
/// The equivalent length of the fitting.
public let value: Double
/// The quantity of the fittings in the path.
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
// TODO: Should these not be optional and we just throw an error or return nil from
// a database query.
/// Represents the max ``EquivalentLength``'s for a project.
///
/// Calculating the duct sizes for a project requires there to be a max supply
/// and a max return equivalent length, so this container represents those values
/// when they exist in the database.
public struct MaxContainer: Codable, Equatable, Sendable {
/// The longest supply equivalent length.
public let supply: EquivalentLength?
/// The longest return equivalent length.
public let `return`: EquivalentLength?
public init(supply: EquivalentLength? = nil, return: EquivalentLength? = nil) {
self.supply = supply
self.return = `return`
}
public var totalEquivalentLength: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
}
}
extension EquivalentLength {
/// The calculated total equivalent length.
///
/// This is the sum of all the straigth lengths and fitting groups (with quantities).
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EquivalentLength.FittingGroup {
/// The calculated total equivalent length for the fitting groups.
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EquivalentLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -0,0 +1,37 @@
import Foundation
import Tagged
extension Tagged where RawValue == Double {
public func string(digits: Int = 2) -> String {
rawValue.string(digits: digits)
}
}
extension Tagged where RawValue == Int {
public func string() -> String {
rawValue.string()
}
}
extension Double {
public func string(digits: Int = 2) -> String {
numberString(self, digits: digits)
}
}
extension Int {
public func string() -> String {
numberString(Double(self), digits: 0)
}
}
private func numberString(_ value: Double, digits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = digits
formatter.groupingSize = 3
formatter.groupingSeparator = ","
formatter.numberStyle = .decimal
return formatter.string(for: value)!
}

View File

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

View File

@@ -0,0 +1,69 @@
/// Holds onto values returned when calculating the design
/// friction rate for a project.
///
/// **NOTE:** This is not stored in the database, it is calculated on the fly.
public struct FrictionRate: Codable, Equatable, Sendable {
/// The available static pressure is the equipment's design static pressure
/// minus the ``ComponentPressureLoss``es for the project.
public let availableStaticPressure: Double
/// The calculated design friction rate value.
public let value: Double
/// Whether the design friction rate is within a valid range.
public var hasErrors: Bool { error != nil }
public init(
availableStaticPressure: Double,
value: Double
) {
self.availableStaticPressure = availableStaticPressure
self.value = value
}
/// The error if the design friction rate is out of a valid range.
public var error: FrictionRateError? {
if value >= 0.18 {
return .init(
"Friction rate should be lower than 0.18",
resolutions: [
"Decrease the blower speed",
"Decrease the blower size",
"Increase the Total Equivalent Length",
]
)
} else if value <= 0.02 {
return .init(
"Friction rate should be higher than 0.02",
resolutions: [
"Increase the blower speed",
"Increase the blower size",
"Decrease the Total Equivalent Length",
]
)
}
return nil
}
}
/// Represents an error when the ``FrictionRate`` is out of a valid range.
///
/// This holds onto the reason for the error as well as possible resolutions.
public struct FrictionRateError: Error, Equatable, Sendable {
/// The reason for the error.
public let reason: String
/// The possible resolutions to the error.
public let resolutions: [String]
public init(
_ reason: String,
resolutions: [String]
) {
self.reason = reason
self.resolutions = resolutions
}
}
#if DEBUG
extension FrictionRate {
public static let mock = Self(availableStaticPressure: 0.21, value: 0.11)
}
#endif

View File

@@ -1,14 +1,29 @@
import Dependencies
import Foundation
/// Represents a single duct design project / system.
///
/// Holds items such as project name and address.
public struct Project: Codable, Equatable, Identifiable, Sendable {
/// The unique ID of the project.
public let id: UUID
/// The name of the project.
public let name: String
/// The street address of the project.
public let streetAddress: String
/// The city of the project.
public let city: String
/// The state of the project.
public let state: String
/// The zip code of the project.
public let zipCode: String
/// The global sensible heat ratio for the project.
///
/// **NOTE:** This is used for calculating the sensible cooling load for rooms.
public let sensibleHeatRatio: Double?
/// When the project was created in the database.
public let createdAt: Date
/// When the project was updated in the database.
public let updatedAt: Date
public init(
@@ -18,6 +33,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -27,33 +43,165 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension Project {
/// Represents the data needed to create a new project.
public struct Create: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String
/// The street address of the project.
public let streetAddress: String
/// The city of the project.
public let city: String
/// The state of the project.
public let state: String
/// The zip code of the project.
public let zipCode: String
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double?
public init(
name: String,
streetAddress: String,
city: String,
state: String,
zipCode: String
zipCode: String,
sensibleHeatRatio: Double? = nil,
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
/// Represents steps that are completed in order to calculate the duct sizes
/// for a project.
///
/// This is primarily used on the web pages to display errors or color of the
/// different steps of a project.
public struct CompletedSteps: Codable, Equatable, Sendable {
/// Whether there is ``EquipmentInfo`` for a project.
public let equipmentInfo: Bool
/// Whether there are ``Room``'s for a project.
public let rooms: Bool
/// Whether there are ``EquivalentLength``'s for a project.
public let equivalentLength: Bool
/// Whether there is a ``FrictionRate`` for a project.
public let frictionRate: Bool
public init(
equipmentInfo: Bool,
rooms: Bool,
equivalentLength: Bool,
frictionRate: Bool
) {
self.equipmentInfo = equipmentInfo
self.rooms = rooms
self.equivalentLength = equivalentLength
self.frictionRate = frictionRate
}
}
/// Represents project details loaded from the database.
///
/// This is generally used to perform duct sizing calculations for the
/// project, once all the steps have been completed.
public struct Detail: Codable, Equatable, Sendable {
/// The project.
public let project: Project
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss]
/// The equipment info for the project.
public let equipmentInfo: EquipmentInfo
/// The equivalent lengths for the project.
public let equivalentLengths: [EquivalentLength]
/// The rooms in the project.
public let rooms: [Room]
/// The trunk sizes in the project.
public let trunks: [TrunkSize]
public init(
project: Project,
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: [EquivalentLength],
rooms: [Room],
trunks: [TrunkSize]
) {
self.project = project
self.componentLosses = componentLosses
self.equipmentInfo = equipmentInfo
self.equivalentLengths = equivalentLengths
self.rooms = rooms
self.trunks = trunks
}
}
/// Represents fields that can be updated for a project that has already been created.
///
/// Only fields that are supplied get updated in the database.
public struct Update: Codable, Equatable, Sendable {
/// The name of the project.
public let name: String?
/// The street address of the project.
public let streetAddress: String?
/// The city of the project.
public let city: String?
/// The state of the project.
public let state: String?
/// The zip code of the project.
public let zipCode: String?
/// The global sensible heat ratio for the project.
public let sensibleHeatRatio: Double?
public init(
name: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
sensibleHeatRatio: Double? = nil
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
}
#if DEBUG
extension Project {
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

@@ -1,20 +1,430 @@
import Dependencies
import Foundation
import Tagged
public struct Room: Codable, Equatable, Sendable {
/// Represents a room in a project.
///
/// This contains data such as the heating and cooling load for the
/// room, the number of registers in the room, and any rectangular
/// duct size calculations stored for the room.
public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The level of the home the room is on.
public let level: Level?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The cooling load required for the room (from Manual-J).
public let coolingLoad: CoolingLoad
/// The number of registers for the room.
public let registerCount: Int
/// An optional room that the airflow is delegated to.
public let delegatedTo: Room.ID?
/// The rectangular duct size calculations for a room.
///
/// **NOTE:** These are optionally set after the round sizes have been calculate
/// for a room.
public let rectangularSizes: [RectangularSize]?
/// When the room was created in the database.
public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
level: Level? = nil,
heatingLoad: Double,
coolingLoad: CoolingLoad,
registerCount: Int = 1
registerCount: Int = 1,
delegatedTo: Room.ID? = nil,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.registerCount = registerCount
self.delegatedTo = delegatedTo
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Represents the cooling load of a room.
///
/// Generally only one of the values is provided by a Manual-J room x room
/// calculation.
///
public struct CoolingLoad: Codable, Equatable, Sendable {
public let total: Double?
public let sensible: Double?
public init(total: Double? = nil, sensible: Double? = nil) {
self.total = total
self.sensible = sensible
}
/// Calculates the cooling load based on the shr.
///
/// Generally Manual-J room x room loads provide either the total load or the
/// sensible load, so this allows us to calculate whichever is not provided.
public func ensured(shr: Double) throws -> (total: Double, sensible: Double) {
switch (total, sensible) {
case (.none, .none):
throw CoolingLoadError("Both the total and sensible loads are nil.")
case (.some(let total), .some(let sensible)):
return (total, sensible)
case (.some(let total), .none):
return (total, total * shr)
case (.none, .some(let sensible)):
return (sensible / shr, sensible)
}
}
}
public enum LevelTag {}
public typealias Level = Tagged<LevelTag, Int>
}
extension Room {
/// Represents the data required to create a new room for a project.
public struct Create: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String
/// An optional level of the home the room is on.
public let level: Room.Level?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
/// An optional room that this room delegates it's airflow to.
public let delegatedTo: Room.ID?
public var coolingLoad: Room.CoolingLoad {
.init(total: coolingTotal, sensible: coolingSensible)
}
public init(
name: String,
level: Room.Level? = nil,
heatingLoad: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int = 1,
delegatedTo: Room.ID? = nil
) {
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.delegatedTo = delegatedTo
}
}
public struct CSV: Equatable, Sendable {
public let file: Data
public init(file: Data) {
self.file = file
}
/// Represents a row in a CSV file.
///
/// This is similar to ``Room.Create``, but since the rooms are not yet
/// created, delegating to another room is done via the room's name
/// instead of id.
///
public struct Row: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String
/// An optional level of the home the room is on.
public let level: Room.Level?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
/// An optional room that this room delegates it's airflow to.
public let delegatedToName: String?
public init(
name: String,
level: Room.Level? = nil,
heatingLoad: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int,
delegatedToName: String? = nil
) {
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
// Treat empty strings as nil, as they are often empty
// when left blank in a CSV file.
self.delegatedToName = delegatedToName == "" ? nil : delegatedToName
}
}
}
/// Represents a rectangular size calculation that is stored in the
/// database for a given room.
///
/// These are done after the round duct sizes have been calculated and
/// can be used to calculate the equivalent rectangular size for a given run.
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the rectangular size.
public let id: UUID
/// The register the rectangular size is associated with.
public let register: Int?
/// The height of the rectangular size, the width gets calculated.
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Onlly fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String?
/// An optional level of the home the room is on.
public let level: Room.Level?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double?
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int?
/// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]?
public var coolingLoad: CoolingLoad? {
guard coolingTotal != nil || coolingSensible != nil else {
return nil
}
return .init(total: coolingTotal, sensible: coolingSensible)
}
public init(
name: String? = nil,
level: Room.Level? = nil,
heatingLoad: Double? = nil,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int? = nil
) {
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = nil
}
public init(
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.level = nil
self.heatingLoad = nil
self.coolingTotal = nil
self.coolingSensible = nil
self.registerCount = nil
self.rectangularSizes = rectangularSizes
}
}
}
extension Array where Element == Room {
/// The sum of heating loads for an array of rooms.
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
/// The sum of total cooling loads for an array of rooms.
public func totalCoolingLoad(shr: Double) throws -> Double {
try reduce(into: 0) { $0 += try $1.coolingLoad.ensured(shr: shr).total }
}
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) throws -> Double {
try reduce(into: 0) {
// let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += try $1.coolingLoad.ensured(shr: shr).sensible
}
}
}
public struct CoolingLoadError: Error, Equatable, Sendable {
public let reason: String
public init(_ reason: String) {
self.reason = reason
}
}
extension Room.Level {
/// The label for the level, i.e. 'Basement' or 'Level-1', etc.
public var label: String {
if rawValue <= 0 {
return "Basement"
}
return "Level-\(rawValue)"
}
}
#if DEBUG
extension Room {
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,
coolingLoad: .init(total: 2472),
// coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingLoad: .init(total: 2916),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingLoad: .init(total: 7446),
// coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingLoad: .init(total: 5096),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingLoad: .init(total: 6829),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingLoad: .init(total: 2076),
// coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -1,65 +0,0 @@
import CasePathsCore
import Foundation
@preconcurrency import URLRouting
extension SiteRoute {
/// Represents api routes.
///
/// The routes return json as opposed to view routes that return html.
public enum Api: Sendable, Equatable {
case project(Self.ProjectRoute)
public static let rootPath = Path {
"api"
"v1"
}
public static let router = OneOf {
Route(.case(Self.project)) {
rootPath
ProjectRoute.router
}
}
}
}
extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create)
case delete(id: Project.ID)
case get(id: Project.ID)
case index
static let rootPath = "projects"
public static let router = OneOf {
Route(.case(Self.create)) {
Path { rootPath }
Method.post
Body(.json(Project.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.delete
}
Route(.case(Self.get(id:))) {
Path {
rootPath
Project.ID.parser()
}
Method.get
}
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
}
}
}

View File

@@ -1,14 +1,20 @@
import CasePathsCore
import FluentKit
import Foundation
@preconcurrency import URLRouting
public enum SiteRoute: Equatable, Sendable {
case api(Self.Api)
case health
case view(Self.View)
public static let router = OneOf {
Route(.case(Self.api)) {
SiteRoute.Api.router
Route(.case(Self.health)) {
Path { "health" }
Method.get
}
Route(.case(Self.view)) {
SiteRoute.View.router
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import Foundation
/// Represents supported color themes for the website.
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua
case cupcake
case cyberpunk
case dark
case `default`
case dracula
case light
case night
case nord
case retro
case synthwave
/// Represents dark color themes.
public static let darkThemes = [
Self.aqua,
Self.cyberpunk,
Self.dark,
Self.dracula,
Self.night,
Self.synthwave,
]
/// Represents light color themes.
public static let lightThemes = [
Self.cupcake,
Self.light,
Self.nord,
Self.retro,
]
}

View File

@@ -0,0 +1,149 @@
import Dependencies
import Foundation
/// Represents trunk calculations for a project.
///
/// These are used to size trunk ducts / runs for multiple rooms or registers.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
/// The unique identifier of the trunk size.
public let id: UUID
/// The project the trunk size is for.
public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [RoomProxy]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: TrunkType,
rooms: [RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
extension TrunkSize {
/// Represents the data needed to create a new ``TrunkSize`` in the database.
public struct Create: Codable, Equatable, Sendable {
/// The project the trunk size is for.
public let projectID: Project.ID
/// The type of the trunk size (supply or return).
public let type: TrunkType
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]]
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
projectID: Project.ID,
type: TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
/// Represents the fields that can be updated on a ``TrunkSize`` in the database.
///
/// Only supplied fields are updated.
public struct Update: Codable, Equatable, Sendable {
/// The type of the trunk size (supply or return).
public let type: TrunkType?
/// The rooms / registers associated with the trunk size.
public let rooms: [Room.ID: [Int]]?
/// An optional rectangular height used to calculate the equivalent
/// rectangular size of the trunk.
public let height: Int?
/// An optional name / label used for identifying the trunk.
public let name: String?
public init(
type: TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
/// A container / wrapper around a ``Room`` that is used with a ``TrunkSize``.
///
/// This is needed because a room can have multiple registers and it is possible
/// that a trunk does not serve all registers in that room.
@dynamicMemberLookup
public struct RoomProxy: Codable, Equatable, Sendable {
/// The room associated with the ``TrunkSize``.
public let room: Room
/// The specific room registers associated with the ``TrunkSize``.
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
public subscript<T>(dynamicMember keyPath: KeyPath<Room, T>) -> T {
room[keyPath: keyPath]
}
}
/// Represents the type of a ``TrunkSize``, either supply or return.
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}
#if DEBUG
extension TrunkSize {
public static func mock(projectID: Project.ID, rooms: [Room]) -> [Self] {
@Dependency(\.uuid) var uuid
let allRooms = rooms.reduce(into: [TrunkSize.RoomProxy]()) { array, room in
var registers = [Int]()
for n in 1...room.registerCount {
registers.append(n)
}
array.append(.init(room: room, registers: registers))
}
return [
.init(id: uuid(), projectID: projectID, type: .supply, rooms: allRooms),
.init(id: uuid(), projectID: projectID, type: .return, rooms: allRooms),
]
}
}
#endif

View File

@@ -0,0 +1,95 @@
import Dependencies
import Foundation
/// Represents a user of the site.
///
public struct User: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the user.
public let id: UUID
/// The user's email address.
public let email: String
/// When the user was created in the database.
public let createdAt: Date
/// When the user was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
email: String,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.email = email
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension User {
/// Represents the data required to create a new user.
public struct Create: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// The password confirmation, must match the password.
public let confirmPassword: String
public init(
email: String,
password: String,
confirmPassword: String
) {
self.email = email
self.password = password
self.confirmPassword = confirmPassword
}
}
/// Represents data required to login a user.
public struct Login: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// An optional page / route to navigate to after logging in the user.
public let next: String?
public init(email: String, password: String, next: String? = nil) {
self.email = email
self.password = password
self.next = next
}
}
/// Represents a user session token, for a logged in user.
public struct Token: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the token.
public let id: UUID
/// The user id the token is for.
public let userID: User.ID
/// The token value.
public let value: String
public init(id: UUID, userID: User.ID, value: String) {
self.id = id
self.userID = userID
self.value = value
}
}
}
#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

@@ -0,0 +1,174 @@
import Dependencies
import Foundation
extension User {
/// Represents a user's profile. Which contains extra information about a user of the site.
public struct Profile: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the profile
public let id: UUID
/// The user id the profile is for.
public let userID: User.ID
/// The user's first name.
public let firstName: String
/// The user's last name.
public let lastName: String
/// The user's company name.
public let companyName: String
/// The user's street address.
public let streetAddress: String
/// The user's city.
public let city: String
/// The user's state.
public let state: String
/// The user's zip code.
public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme?
/// When the profile was created in the database.
public let createdAt: Date
/// When the profile was updated in the database.
public let updatedAt: Date
public init(
id: UUID,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
}
extension User.Profile {
/// Represents the data required to create a user profile.
public struct Create: Codable, Equatable, Sendable {
/// The user id the profile is for.
public let userID: User.ID
/// The user's first name.
public let firstName: String
/// The user's last name.
public let lastName: String
/// The user's company name.
public let companyName: String
/// The user's street address.
public let streetAddress: String
/// The user's city.
public let city: String
/// The user's state.
public let state: String
/// The user's zip code.
public let zipCode: String
/// An optional theme that the user prefers.
public let theme: Theme?
public init(
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
/// Represents the fields that can be updated on a user's profile.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// The user's first name.
public let firstName: String?
/// The user's last name.
public let lastName: String?
/// The user's company name.
public let companyName: String?
/// The user's street address.
public let streetAddress: String?
/// The user's city.
public let city: String?
/// The user's state.
public let state: String?
/// The user's zip code.
public let zipCode: String?
/// An optional theme that the user prefers.
public let theme: Theme?
public init(
firstName: String? = nil,
lastName: String? = nil,
companyName: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
theme: Theme? = nil
) {
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
}
#if DEBUG
extension User.Profile {
public static func mock(userID: User.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
userID: userID,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "1234 Sesame St",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -0,0 +1,168 @@
import Dependencies
import DependenciesMacros
import Elementary
import EnvVars
import FileClient
import Foundation
import ManualDCore
extension DependencyValues {
/// Access the pdf client dependency that can be used to generate pdf's for
/// a project.
public var pdfClient: PdfClient {
get { self[PdfClient.self] }
set { self[PdfClient.self] = newValue }
}
}
@DependencyClient
public struct PdfClient: Sendable {
/// Generate the html used to convert to pdf for a project.
public var html: @Sendable (Request) async throws -> (any HTML & Sendable)
/// Converts the generated html to a pdf.
///
/// **NOTE:** This is generally not used directly, instead use the overload that accepts a request,
/// which generates the html and does the conversion all in one step.
public var generatePdf: @Sendable (Project.ID, any HTML & Sendable) async throws -> Response
/// Generate a pdf for the given project request.
///
/// - Parameters:
/// - request: The project data used to generate the pdf.
public func generatePdf(request: Request) async throws -> Response {
try await self.generatePdf(request.project.id, html(request))
}
}
extension PdfClient: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
html: { request in
request.toHTML()
},
generatePdf: { projectID, html in
@Dependency(\.fileClient) var fileClient
@Dependency(\.environment) var environment
let baseUrl = "/tmp/\(projectID)"
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: environment.pandocPath)
process.arguments = [
"\(baseUrl).html",
"--pdf-engine=\(environment.pdfEngine)",
"--from=html",
"--css=Public/css/pdf.css",
"--output=\(baseUrl).pdf",
]
try process.run()
process.waitUntilExit()
return .init(htmlPath: "\(baseUrl).html", pdfPath: "\(baseUrl).pdf")
}
)
}
extension PdfClient {
/// Represents the data required to generate a pdf for a given project.
public struct Request: Codable, Equatable, Sendable {
/// The project we're generating a pdf for.
public let project: Project
/// The rooms in the project.
public let rooms: [Room]
/// The component pressure losses for the project.
public let componentLosses: [ComponentPressureLoss]
/// The calculated duct sizes for the project.
public let ductSizes: DuctSizes
/// The equipment information for the project.
public let equipmentInfo: EquipmentInfo
/// The max supply equivalent length for the project.
public let maxSupplyTEL: EquivalentLength
/// The max return equivalent length for the project.
public let maxReturnTEL: EquivalentLength
/// The calculated design friction rate for the project.
public let frictionRate: FrictionRate
/// The project wide sensible heat ratio.
public let projectSHR: Double
var totalEquivalentLength: Double {
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
}
public init(
project: Project,
rooms: [Room],
componentLosses: [ComponentPressureLoss],
ductSizes: DuctSizes,
equipmentInfo: EquipmentInfo,
maxSupplyTEL: EquivalentLength,
maxReturnTEL: EquivalentLength,
frictionRate: FrictionRate,
projectSHR: Double
) {
self.project = project
self.rooms = rooms
self.componentLosses = componentLosses
self.ductSizes = ductSizes
self.equipmentInfo = equipmentInfo
self.maxSupplyTEL = maxSupplyTEL
self.maxReturnTEL = maxReturnTEL
self.frictionRate = frictionRate
self.projectSHR = projectSHR
}
}
/// Represents the response after generating a pdf.
public struct Response: Equatable, Sendable {
/// The path to the html file used to generate the pdf from.
public let htmlPath: String
/// The path to the pdf file.
public let pdfPath: String
public init(htmlPath: String, pdfPath: String) {
self.htmlPath = htmlPath
self.pdfPath = pdfPath
}
}
}
#if DEBUG
extension PdfClient.Request {
public static func mock(project: Project = .mock) -> Self {
let rooms = Room.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
let equipmentInfo = EquipmentInfo.mock(projectID: project.id)
let equivalentLengths = EquivalentLength.mock(projectID: project.id)
return .init(
project: project,
rooms: rooms,
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
ductSizes: .mock(
equipmentInfo: equipmentInfo,
rooms: rooms,
trunks: trunks,
shr: project.sensibleHeatRatio ?? 0.83
),
equipmentInfo: equipmentInfo,
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,
frictionRate: .mock,
projectSHR: 0.83
)
}
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,86 @@
import Dependencies
import DependenciesMacros
import Elementary
import ManualDClient
import ManualDCore
import Vapor
extension DependencyValues {
public var projectClient: ProjectClient {
get { self[ProjectClient.self] }
set { self[ProjectClient.self] = newValue }
}
}
/// Useful helper utilities for project's.
///
/// This is primarily used for implementing logic required to get the needed data
/// for the view controller to render views.
@DependencyClient
public struct ProjectClient: Sendable {
/// Calculates the room duct sizes for the given project.
public var calculateRoomDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.RoomContainer]
/// Calculates the trunk duct sizes for the given project.
public var calculateTrunkDuctSizes:
@Sendable (Project.ID) async throws -> [DuctSizes.TrunkContainer]
public var createProject:
@Sendable (User.ID, Project.Create) async throws -> CreateProjectResponse
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
public var generatePdf: @Sendable (Project.ID) async throws -> Response
public func calculateDuctSizes(_ projectID: Project.ID) async throws -> DuctSizes {
.init(
rooms: try await calculateRoomDuctSizes(projectID),
trunks: try await calculateTrunkDuctSizes(projectID)
)
}
}
extension ProjectClient: TestDependencyKey {
public static let testValue = Self()
}
extension ProjectClient {
public struct CreateProjectResponse: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let rooms: [Room]
public let sensibleHeatRatio: Double?
public let completedSteps: Project.CompletedSteps
public init(
projectID: Project.ID,
rooms: [Room],
sensibleHeatRatio: Double? = nil,
completedSteps: Project.CompletedSteps
) {
self.projectID = projectID
self.rooms = rooms
self.sensibleHeatRatio = sensibleHeatRatio
self.completedSteps = completedSteps
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let componentLosses: [ComponentPressureLoss]
public let equivalentLengths: EquivalentLength.MaxContainer
public let frictionRate: FrictionRate?
public init(
componentLosses: [ComponentPressureLoss],
equivalentLengths: EquivalentLength.MaxContainer,
frictionRate: FrictionRate? = nil
) {
self.componentLosses = componentLosses
self.equivalentLengths = equivalentLengths
self.frictionRate = frictionRate
}
}
}

View File

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

View File

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

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