78 Commits

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

View File

@@ -0,0 +1,65 @@
name: Create and publish a Docker image
# Configures this workflow to run every time a change is pushed to the branch called `release`.
on:
push:
# branches: ['main']
tags:
- '*.*.*'
workflow_dispatch:
# Defines two custom environment variables for the workflow. These are used for the Container registry domain,
# and a name for the Docker image that this workflow builds.
env:
REGISTRY: git.housh.dev
IMAGE_NAME: ${{ gitea.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Uses the `docker/login-action` action to log in to the Container registry registry using the account
# and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CONTAINER_TOKEN }}
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels
# that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a
# subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
type=raw,value=prod
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"
# in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

4
.gitignore vendored
View File

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

View File

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

View File

@@ -7,7 +7,13 @@ let package = Package(
products: [
.executable(name: "App", targets: ["App"]),
.library(name: "ApiController", targets: ["ApiController"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
.library(name: "EnvClient", targets: ["EnvClient"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
.library(name: "PdfClient", targets: ["PdfClient"]),
.library(name: "ProjectClient", targets: ["ProjectClient"]),
.library(name: "ManualDCore", targets: ["ManualDCore"]),
.library(name: "ManualDClient", targets: ["ManualDClient"]),
.library(name: "Styleguide", targets: ["Styleguide"]),
@@ -19,6 +25,7 @@ let package = Package(
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
.package(url: "https://github.com/pointfreeco/swift-url-routing.git", from: "0.6.2"),
.package(url: "https://github.com/pointfreeco/vapor-routing.git", from: "0.1.3"),
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", from: "1.6.0"),
@@ -31,6 +38,7 @@ let package = Package(
name: "App",
dependencies: [
.target(name: "ApiController"),
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "ViewController"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -53,6 +61,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "AuthClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "DatabaseClient",
dependencies: [
@@ -63,10 +80,66 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "EnvClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
]
),
.target(
name: "FileClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "HTMLSnapshotTesting",
dependencies: [
.product(name: "Elementary", package: "elementary"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
),
.target(
name: "PdfClient",
dependencies: [
.target(name: "EnvClient"),
.target(name: "FileClient"),
.target(name: "ManualDCore"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary"),
]
),
.testTarget(
name: "PdfClientTests",
dependencies: [
.target(name: "HTMLSnapshotTesting"),
.target(name: "PdfClient"),
.product(name: "SnapshotTesting", package: "swift-snapshot-testing"),
]
// ,
// resources: [
// .copy("__Snapshots__")
// ]
),
.target(
name: "ProjectClient",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "PdfClient"),
.product(name: "Vapor", package: "vapor"),
]
),
.target(
name: "ManualDCore",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
]
@@ -103,6 +176,11 @@ let package = Package(
.target(
name: "ViewController",
dependencies: [
.target(name: "AuthClient"),
.target(name: "DatabaseClient"),
.target(name: "PdfClient"),
.target(name: "ProjectClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),
.product(name: "Dependencies", package: "swift-dependencies"),
@@ -112,5 +190,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.testTarget(
name: "ViewControllerTests",
dependencies: [
.target(name: "ViewController"),
.target(name: "HTMLSnapshotTesting"),
],
resources: [
.copy("__Snapshots__")
]
),
]
)

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

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

View File

@@ -1,5 +1,4 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark --prefersdark, dracula;
themes: all;
}

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.

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

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

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

@@ -31,6 +31,15 @@ extension SiteRoute.Api.ProjectRoute {
case .delete(let id):
try await database.projects.delete(id)
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()
}
case .get(let id):
guard let project = try await database.projects.get(id) else {
logger.error("Project not found for id: \(id)")

View File

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

View File

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

View File

@@ -6,19 +6,18 @@ import Vapor
private let viewRouteMiddleware: [any Middleware] = [
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware(path: "/login"),
User.redirectMiddleware { req in
"/login?next=\(req.url.string)"
},
]
extension SiteRoute.View {
var middleware: [any Middleware]? {
switch self {
case .project,
.frictionRate,
.effectiveLength,
.room:
return viewRouteMiddleware
case .login, .signup:
case .login, .signup, .test:
return nil
case .project, .user:
return viewRouteMiddleware
}
}
}

View File

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

View File

@@ -0,0 +1,51 @@
import DatabaseClient
import Dependencies
import DependenciesMacros
import ManualDCore
import Vapor
extension DependencyValues {
public var authClient: AuthClient {
get { self[AuthClient.self] }
set { self[AuthClient.self] = newValue }
}
}
@DependencyClient
public struct AuthClient: Sendable {
public var createAndLogin: @Sendable (User.Create) async throws -> User
public var currentUser: @Sendable () throws -> User
public var login: @Sendable (User.Login) async throws -> User
public var logout: @Sendable () throws -> Void
}
extension AuthClient: TestDependencyKey {
public static let testValue = Self()
public static func live(on request: Request) -> Self {
@Dependency(\.database) var database
return .init(
createAndLogin: { createForm in
let user = try await database.users.create(createForm)
_ = try await database.users.login(
.init(email: createForm.email, password: createForm.password)
)
request.auth.login(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
currentUser: {
try request.auth.require(User.self)
},
login: { loginForm in
let token = try await database.users.login(loginForm)
let user = try await database.users.get(token.userID)!
request.session.authenticate(user)
request.logger.debug("LOGGED IN: \(user.id)")
return user
},
logout: { request.auth.logout(User.self) }
)
}
}

View File

@@ -10,8 +10,11 @@ extension DatabaseClient {
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 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
}
}
@@ -34,18 +37,26 @@ extension DatabaseClient.ComponentLoss {
try await model.delete(on: database)
},
fetch: { projectID in
guard
let model = try await ComponentLossModel.query(on: database)
.filter("projectID", .equal, projectID)
.first()
else {
throw NotFoundError()
}
return try model.toDTO()
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.save(on: database)
}
return try model.toDTO()
}
)
}
@@ -71,6 +82,24 @@ extension ComponentPressureLoss.Create {
}
}
extension ComponentPressureLoss.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
}
if let value {
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
}
extension ComponentPressureLoss {
struct Migrate: AsyncMigration {
let name = "CreateComponentLoss"
@@ -82,8 +111,10 @@ extension ComponentPressureLoss {
.field("value", .double, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.unique(on: "projectID", "name")
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
// .unique(on: "projectID", "name")
.create()
}
@@ -143,4 +174,13 @@ final class ComponentLossModel: Model, @unchecked Sendable {
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
}
}
}

View File

@@ -9,8 +9,11 @@ extension DatabaseClient {
public struct EffectiveLengthClient: Sendable {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EffectiveLength?
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
public var update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
}
}
@@ -31,18 +34,41 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
try await model.delete(on: database)
},
fetch: { projectID in
guard
let model = try await EffectiveLengthModel.query(on: database)
.filter("projectID", .equal, projectID)
.first()
else {
throw NotFoundError()
}
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
)
return try model.toDTO()
},
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.save(on: database)
}
return try model.toDTO()
}
)
}
@@ -82,7 +108,9 @@ extension EffectiveLength {
.field("groups", .data)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name", "type")
.create()
}
@@ -157,4 +185,19 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EffectiveLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
self.straightLengths = straightLengths
}
if let groups = updates.groups {
self.groups = try JSONEncoder().encode(groups)
}
}
}

View File

@@ -11,6 +11,8 @@ extension DatabaseClient {
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
}
}
@@ -38,12 +40,23 @@ extension DatabaseClient.Equipment {
.filter("projectID", .equal, projectId)
.first()
else {
throw NotFoundError()
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()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
@@ -77,6 +90,30 @@ extension EquipmentInfo.Create {
}
}
extension EquipmentInfo.Update {
var hasUpdates: Bool {
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
}
func validate() throws(ValidationError) {
if let staticPressure {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
}
if let heatingCFM {
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
if let coolingCFM {
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
}
extension EquipmentInfo {
struct Migrate: AsyncMigration {
@@ -91,7 +128,9 @@ extension EquipmentInfo {
.field("coolingCFM", .int16, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID")
.create()
}
@@ -159,4 +198,16 @@ final class EquipmentModel: Model, @unchecked Sendable {
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
}
}
}

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

@@ -19,6 +19,8 @@ public struct DatabaseClient: Sendable {
public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient
public var users: Users
public var userProfile: UserProfile
public var trunkSizes: TrunkSizes
}
extension DatabaseClient: TestDependencyKey {
@@ -29,7 +31,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
users: .testValue
users: .testValue,
userProfile: .testValue,
trunkSizes: .testValue
)
public static func live(database: any Database) -> Self {
@@ -40,7 +44,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database),
componentLoss: .live(database: database),
effectiveLength: .live(database: database),
users: .live(database: database)
users: .live(database: database),
userProfile: .live(database: database),
trunkSizes: .live(database: database)
)
}
}
@@ -49,6 +55,10 @@ extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.run()
}
}
}
@@ -60,13 +70,15 @@ extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self(
run: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
Project.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
TrunkSize.Migrate(),
]
}
)

View File

@@ -9,8 +9,12 @@ extension DatabaseClient {
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
}
@@ -30,9 +34,95 @@ extension DatabaseClient.Projects: TestDependencyKey {
}
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil
{
equivalentLengthsCompleted = true
}
let componentLosses = try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equipmentInfo = try await EquipmentModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.first()
return .init(
equipmentInfo: equipmentInfo != nil,
rooms: roomsCount > 0,
equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0
)
},
getSensibleHeatRatio: { id in
guard
let shr = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return shr.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
.sort(\.$createdAt, .descending)
@@ -40,6 +130,17 @@ extension DatabaseClient.Projects: TestDependencyKey {
.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()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
@@ -75,6 +176,53 @@ extension Project.Create {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
@@ -90,6 +238,7 @@ extension Project {
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("sensibleHeatRatio", .double)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
@@ -126,6 +275,9 @@ final class ProjectModel: Model, @unchecked Sendable {
@Field(key: "zipCode")
var zipCode: String
@Field(key: "sensibleHeatRatio")
var sensibleHeatRatio: Double?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -135,6 +287,18 @@ final class ProjectModel: Model, @unchecked Sendable {
@Children(for: \.$project)
var componentLosses: [ComponentLossModel]
@OptionalChild(for: \.$project)
var equipment: EquipmentModel?
@Children(for: \.$project)
var equivalentLengths: [EffectiveLengthModel]
@Children(for: \.$project)
var rooms: [RoomModel]
@Children(for: \.$project)
var trunks: [TrunkModel]
@Parent(key: "userID")
var user: UserModel
@@ -147,6 +311,7 @@ final class ProjectModel: Model, @unchecked Sendable {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
userID: User.ID,
createdAt: Date? = nil,
updatedAt: Date? = nil
@@ -155,9 +320,9 @@ final class ProjectModel: Model, @unchecked Sendable {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
$user.id = userID
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -171,8 +336,32 @@ final class ProjectModel: Model, @unchecked Sendable {
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
}
}
}

View File

@@ -0,0 +1,175 @@
// import Dependencies
// import DependenciesMacros
// import Fluent
// import Foundation
// import ManualDCore
//
// extension DatabaseClient {
// @DependencyClient
// public struct RectangularDuct: Sendable {
// public var create:
// @Sendable (DuctSizing.RectangularDuct.Create) async throws -> DuctSizing.RectangularDuct
// public var delete: @Sendable (DuctSizing.RectangularDuct.ID) async throws -> Void
// public var fetch: @Sendable (Room.ID) async throws -> [DuctSizing.RectangularDuct]
// public var get:
// @Sendable (DuctSizing.RectangularDuct.ID) async throws -> DuctSizing.RectangularDuct?
// public var update:
// @Sendable (DuctSizing.RectangularDuct.ID, DuctSizing.RectangularDuct.Update) async throws ->
// DuctSizing.RectangularDuct
// }
// }
//
// extension DatabaseClient.RectangularDuct: TestDependencyKey {
// public static let testValue = Self()
//
// public static func live(database: any Database) -> Self {
// .init(
// create: { request in
// try request.validate()
// let model = request.toModel()
// try await model.save(on: database)
// return try model.toDTO()
// },
// delete: { id in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try await model.delete(on: database)
// },
// fetch: { roomID in
// try await RectangularDuctModel.query(on: database)
// .with(\.$room)
// .filter(\.$room.$id == roomID)
// .all()
// .map { try $0.toDTO() }
// },
// get: { id in
// try await RectangularDuctModel.find(id, on: database)
// .map { try $0.toDTO() }
// },
// update: { id, updates in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try updates.validate()
// model.applyUpdates(updates)
// if model.hasChanges {
// try await model.save(on: database)
// }
// return try model.toDTO()
// }
// )
// }
// }
//
// extension DuctSizing.RectangularDuct.Create {
//
// func validate() throws(ValidationError) {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
//
// func toModel() -> RectangularDuctModel {
// .init(roomID: roomID, height: height)
// }
// }
//
// extension DuctSizing.RectangularDuct.Update {
//
// func validate() throws(ValidationError) {
// if let height {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
// }
//
// extension DuctSizing.RectangularDuct {
// struct Migrate: AsyncMigration {
// let name = "CreateRectangularDuct"
//
// func prepare(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema)
// .id()
// .field("register", .int8)
// .field("height", .int8, .required)
// .field("roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade))
// .field("createdAt", .datetime)
// .field("updatedAt", .datetime)
// .create()
// }
//
// func revert(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema).delete()
// }
// }
// }
//
// final class RectangularDuctModel: Model, @unchecked Sendable {
//
// static let schema = "rectangularDuct"
//
// @ID(key: .id)
// var id: UUID?
//
// @Parent(key: "roomID")
// var room: RoomModel
//
// @Field(key: "height")
// var height: Int
//
// @Field(key: "register")
// var register: Int?
//
// @Timestamp(key: "createdAt", on: .create, format: .iso8601)
// var createdAt: Date?
//
// @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
// var updatedAt: Date?
//
// init() {}
//
// init(
// id: UUID? = nil,
// roomID: Room.ID,
// register: Int? = nil,
// height: Int
// ) {
// self.id = id
// $room.id = roomID
// self.register = register
// self.height = height
// }
//
// func toDTO() throws -> DuctSizing.RectangularDuct {
// return try .init(
// id: requireID(),
// roomID: $room.id,
// register: register,
// height: height,
// createdAt: createdAt!,
// updatedAt: updatedAt!
// )
// }
//
// func applyUpdates(_ updates: DuctSizing.RectangularDuct.Update) {
// if let height = updates.height, height != self.height {
// self.height = height
// }
// if let register = updates.register, register != self.register {
// self.register = register
// }
// }
// }

View File

@@ -9,15 +9,18 @@ extension DatabaseClient {
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, 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
}
}
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Rooms {
public static func live(database: any Database) -> Self {
.init(
create: { request in
@@ -31,8 +34,53 @@ extension DatabaseClient.Rooms {
}
try await model.delete(on: database)
},
deleteRectangularSize: { roomID, rectangularDuctID in
guard let model = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID
}
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
},
get: { id in
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
},
fetch: { projectID in
try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.sort(\.$name, .ascending)
.all()
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
},
updateRectangularSize: { id, size in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
var rectangularSizes = model.rectangularSizes ?? []
rectangularSizes.removeAll {
$0.id == size.id
}
rectangularSizes.append(size)
model.rectangularSizes = rectangularSizes
try await model.save(on: database)
return try model.toDTO()
}
)
}
@@ -62,8 +110,10 @@ extension Room.Create {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
@@ -71,6 +121,37 @@ extension Room.Create {
}
}
extension Room.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
}
if let heatingLoad {
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
}
if let coolingTotal {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
if let registerCount {
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
}
extension Room {
struct Migrate: AsyncMigration {
let name = "CreateRoom"
@@ -81,9 +162,14 @@ extension Room {
.field("name", .string, .required)
.field("heatingLoad", .double, .required)
.field("coolingTotal", .double, .required)
.field("coolingSensible", .double, .required)
.field("coolingSensible", .double)
.field("registerCount", .int8, .required)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name")
.create()
}
@@ -111,11 +197,14 @@ final class RoomModel: Model, @unchecked Sendable {
var coolingTotal: Double
@Field(key: "coolingSensible")
var coolingSensible: Double
var coolingSensible: Double?
@Field(key: "registerCount")
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -132,8 +221,9 @@ final class RoomModel: Model, @unchecked Sendable {
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
@@ -144,6 +234,7 @@ final class RoomModel: Model, @unchecked Sendable {
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
@@ -155,11 +246,36 @@ final class RoomModel: Model, @unchecked Sendable {
projectID: $project.id,
name: name,
heatingLoad: heatingLoad,
coolingLoad: .init(total: coolingTotal, sensible: coolingSensible),
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Room.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
self.coolingTotal = coolingTotal
}
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
self.coolingSensible = coolingSensible
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
}
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
self.rectangularSizes = rectangularSizes
}
}
}

View File

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

View File

@@ -0,0 +1,283 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct UserProfile: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
}
extension DatabaseClient.UserProfile: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
try profile.validate()
let model = profile.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { userID in
try await UserProfileModel.query(on: database)
.with(\.$user)
.filter(\.$user.$id == userID)
.first()
.map { try $0.toDTO() }
},
get: { id in
try await UserProfileModel.find(id, on: database)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
func toModel() -> UserProfileModel {
.init(
userID: userID,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: theme
)
}
}
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
if let companyName {
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
let name = "Create UserProfile"
func prepare(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema)
.id()
.field("firstName", .string, .required)
.field("lastName", .string, .required)
.field("companyName", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("theme", .string)
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "userID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema).delete()
}
}
}
final class UserProfileModel: Model, @unchecked Sendable {
static let schema = "user_profile"
@ID(key: .id)
var id: UUID?
@Parent(key: "userID")
var user: UserModel
@Field(key: "firstName")
var firstName: String
@Field(key: "lastName")
var lastName: String
@Field(key: "companyName")
var companyName: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "theme")
var theme: String?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.id = id
$user.id = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme?.rawValue
}
func toDTO() throws -> User.Profile {
try .init(
id: requireID(),
userID: $user.id,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: self.theme.flatMap(Theme.init),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: User.Profile.Update) {
if let firstName = updates.firstName, firstName != self.firstName {
self.firstName = firstName
}
if let lastName = updates.lastName, lastName != self.lastName {
self.lastName = lastName
}
if let companyName = updates.companyName, companyName != self.companyName {
self.companyName = companyName
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let theme = updates.theme, theme.rawValue != self.theme {
self.theme = theme.rawValue
}
}
}

View File

@@ -80,12 +80,11 @@ extension User {
func prepare(on database: any Database) async throws {
try await database.schema(UserModel.schema)
.id()
.field("username", .string, .required)
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "email", "username")
.unique(on: "email")
.create()
}
@@ -104,6 +103,8 @@ extension User.Token {
.id()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value")
.create()
}
@@ -126,13 +127,10 @@ extension User.Create {
func toModel() throws -> UserModel {
try validate()
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
return try .init(email: email, passwordHash: User.hashPassword(password))
}
func validate() throws {
guard !username.isEmpty else {
throw ValidationError("Username should not be empty.")
}
guard !email.isEmpty else {
throw ValidationError("Email should not be empty")
}
@@ -152,9 +150,6 @@ final class UserModel: Model, @unchecked Sendable {
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "email")
var email: String
@@ -174,12 +169,10 @@ final class UserModel: Model, @unchecked Sendable {
init(
id: UUID? = nil,
username: String,
email: String,
passwordHash: String
) {
self.id = id
self.username = username
self.email = email
self.passwordHash = passwordHash
}
@@ -188,7 +181,6 @@ final class UserModel: Model, @unchecked Sendable {
try .init(
id: requireID(),
email: email,
username: username,
createdAt: createdAt!,
updatedAt: updatedAt!
)
@@ -237,7 +229,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable {
extension User: Authenticatable {}
extension User: SessionAuthenticatable {
public var sessionID: String { username }
public var sessionID: String { email }
}
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
@@ -248,7 +240,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == basic.username)
.filter(\UserModel.$email == basic.username)
.first(),
try user.verifyPassword(basic.password)
else {
@@ -284,7 +276,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == sessionID)
.filter(\UserModel.$email == sessionID)
.first()
else {
throw Abort(.unauthorized)

View File

@@ -0,0 +1,74 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var env: @Sendable () throws -> EnvVars {
get { self[EnvClient.self].env }
set { self[EnvClient.self].env = newValue }
}
}
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
}
}
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
})
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -0,0 +1,40 @@
import Dependencies
import DependenciesMacros
import Foundation
import Vapor
extension DependencyValues {
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
@DependencyClient
public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void
public var writeFile: @Sendable (String, String) async throws -> Void
public var removeFile: @Sendable (String) async throws -> Void
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response
}
extension FileClient: TestDependencyKey {
public static let testValue = Self()
public static func live(fileIO: FileIO) -> Self {
.init(
writeFile: { contents, path in
try await fileIO.writeFile(ByteBuffer(string: contents), at: path)
},
removeFile: { path in
try FileManager.default.removeItem(atPath: path)
},
streamFile: { path, onComplete in
try await fileIO.asyncStreamFile(at: path) { _ in
try await onComplete()
}
}
)
}
}

View File

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

View File

@@ -1,6 +1,51 @@
import Foundation
import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
extension TrunkSize.RoomProxy {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
// no longer exist.
private var actualRegisterCount: Int {
guard registers.count <= room.registerCount else {
return room.registerCount
}
return registers.count
}
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
extension TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
@@ -19,6 +64,8 @@ func roundSize(_ size: Double) throws -> Int {
throw ManualDError(message: "Size should be less than 24.")
}
// let size = size.rounded(.toNearestOrEven)
switch size {
case 0..<4:
return 4

View File

@@ -1,20 +1,8 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRateResponse
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
@@ -22,8 +10,25 @@ extension DependencyValues {
}
}
// MARK: Duct Size
/// Performs manual-d duct sizing calculations.
///
///
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
public var rectangularSize:
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension ManualDClient {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
@@ -39,36 +44,33 @@ extension ManualDClient {
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let ductulatorSize: Double
public let calculatedSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
ductulatorSize: Double,
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.ductulatorSize = ductulatorSize
self.calculatedSize = calculatedSize
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 componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: ComponentPressureLosses,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
@@ -87,11 +89,8 @@ extension ManualDClient {
self.frictionRate = frictionRate
}
}
}
// MARK: Total Effective Length
extension ManualDClient {
public struct TotalEffectiveLengthRequest: Codable, Equatable, Sendable {
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
@@ -107,11 +106,8 @@ extension ManualDClient {
self.effectiveLengthGroups = effectiveLengthGroups
}
}
}
// MARK: Equivalent Rectangular Duct
extension ManualDClient {
public struct EquivalentRectangularDuctRequest: Codable, Equatable, Sendable {
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
@@ -121,7 +117,7 @@ extension ManualDClient {
}
}
public struct EquivalentRectangularDuctResponse: Codable, Equatable, Sendable {
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int

View File

@@ -13,7 +13,7 @@ extension ManualDClient: DependencyKey {
let finalSize = try roundSize(ductulatorSize)
let flexSize = try flexSize(request)
return .init(
ductulatorSize: ductulatorSize,
calculatedSize: ductulatorSize,
finalSize: finalSize,
flexSize: flexSize,
velocity: velocity(cfm: request.designCFM, roundSize: finalSize)
@@ -25,36 +25,20 @@ extension ManualDClient: DependencyKey {
throw ManualDError(message: "Total Effective Length should be greater than 0.")
}
let totalComponentLosses = request.componentPressureLosses.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)
return .init(availableStaticPressure: availableStaticPressure, value: frictionRate)
},
totalEffectiveLength: { request in
totalEquivalentLength: { request in
let trunkLengths = request.trunkLengths.reduce(0) { $0 + $1 }
let runoutLengths = request.runoutLengths.reduce(0) { $0 + $1 }
let groupLengths = request.effectiveLengthGroups.totalEffectiveLength
return trunkLengths + runoutLengths + groupLengths
},
equivalentRectangularDuct: { request in
rectangularSize: { request in
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
// Round the width up or fail (really should never fail since we know the input is a number).
guard let widthStr = numberFormatter.string(for: width),
let widthInt = Int(widthStr)
else {
throw ManualDError(
message: "Failed to convert to to rectangular duct size, width: \(width)"
)
}
return .init(height: request.height, width: widthInt)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
}
)
}
private let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
formatter.roundingMode = .ceiling
return formatter
}()

View File

@@ -1,3 +1,4 @@
import Dependencies
import Foundation
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
@@ -42,6 +43,35 @@ extension ComponentPressureLoss {
self.name = name
self.value = value
}
// Return's commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] {
[
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
.init(projectID: projectID, name: "return-grille", value: 0.03),
.init(projectID: projectID, name: "balancing-damper", value: 0.03),
]
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let value: Double?
public init(
name: String? = nil,
value: Double? = nil
) {
self.name = name
self.value = value
}
}
}
extension Array where Element == ComponentPressureLoss {
public var total: Double {
reduce(into: 0) { $0 += $1.value }
}
}
@@ -60,7 +90,61 @@ public typealias ComponentPressureLosses = [String: Double]
}
}
extension Array where Element == ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> Self {
ComponentPressureLoss.mock(projectID: projectID)
}
}
extension ComponentPressureLoss {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "evaporator-coil",
value: 0.2,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "filter",
value: 0.1,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "supply-outlet",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "return-grille",
value: 0.03,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "balancing-damper",
value: 0.03,
createdAt: now,
updatedAt: now
),
]
}
public static var mock: [Self] {
[
.init(

View File

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

View File

@@ -61,6 +61,26 @@ extension EffectiveLength {
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let type: EffectiveLengthType?
public let straightLengths: [Int]?
public let groups: [Group]?
public init(
name: String? = nil,
type: EffectiveLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EffectiveLength.Group]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
@@ -85,11 +105,81 @@ extension EffectiveLength {
self.quantity = quantity
}
}
public struct MaxContainer: Codable, Equatable, Sendable {
public let supply: EffectiveLength?
public let `return`: EffectiveLength?
public var total: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
self.supply = supply
self.return = `return`
}
}
}
extension EffectiveLength {
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EffectiveLength.Group {
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EffectiveLength {
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Supply - 1",
type: .supply,
straightLengths: [10, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 30, quantity: 1),
.init(group: 3, letter: "a", value: 10, quantity: 1),
.init(group: 12, letter: "a", value: 10, quantity: 1),
],
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Return - 1",
type: .return,
straightLengths: [10, 20, 5],
groups: [
.init(group: 5, letter: "a", value: 10),
.init(group: 6, letter: "a", value: 15, quantity: 1),
.init(group: 7, letter: "a", value: 20, quantity: 1),
],
createdAt: now,
updatedAt: now
),
]
}
public static let mocks: [Self] = [
.init(
id: UUID(0),

View File

@@ -50,10 +50,41 @@ extension EquipmentInfo {
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),

View File

@@ -0,0 +1,57 @@
/// Holds onto values returned when calculating the design
/// friction rate for a project.
public struct FrictionRate: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let value: Double
public var hasErrors: Bool { error != nil }
public init(
availableStaticPressure: Double,
value: Double
) {
self.availableStaticPressure = availableStaticPressure
self.value = value
}
public var error: FrictionRateError? {
if value >= 0.18 {
return .init(
"Friction rate should be lower than 0.18",
resolutions: [
"Decrease the blower speed",
"Decrease the blower size",
"Increase the Total Equivalent Length",
]
)
} else if value <= 0.02 {
return .init(
"Friction rate should be higher than 0.02",
resolutions: [
"Increase the blower speed",
"Increase the blower size",
"Decrease the Total Equivalent Length",
]
)
}
return nil
}
}
public struct FrictionRateError: Error, Equatable, Sendable {
public let reason: String
public let resolutions: [String]
public init(
_ reason: String,
resolutions: [String]
) {
self.reason = reason
self.resolutions = resolutions
}
}
#if DEBUG
extension FrictionRate {
public static let mock = Self(availableStaticPressure: 0.21, value: 0.11)
}
#endif

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
public let city: String
public let state: String
public let zipCode: String
public let sensibleHeatRatio: Double?
public let createdAt: Date
public let updatedAt: Date
@@ -19,6 +20,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -28,6 +30,7 @@ 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
}
@@ -42,6 +45,7 @@ extension Project {
public let city: String
public let state: String
public let zipCode: String
public let sensibleHeatRatio: Double?
public init(
name: String,
@@ -49,12 +53,86 @@ extension Project {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
public struct CompletedSteps: Codable, Equatable, Sendable {
public let equipmentInfo: Bool
public let rooms: Bool
public let equivalentLength: Bool
public let frictionRate: Bool
public init(
equipmentInfo: Bool,
rooms: Bool,
equivalentLength: Bool,
frictionRate: Bool
) {
self.equipmentInfo = equipmentInfo
self.rooms = rooms
self.equivalentLength = equivalentLength
self.frictionRate = frictionRate
}
}
public struct Detail: Codable, Equatable, Sendable {
public let project: Project
public let componentLosses: [ComponentPressureLoss]
public let equipmentInfo: EquipmentInfo
public let equivalentLengths: [EffectiveLength]
public let rooms: [Room]
public let trunks: [TrunkSize]
public init(
project: Project,
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: [EffectiveLength],
rooms: [Room],
trunks: [TrunkSize]
) {
self.project = project
self.componentLosses = componentLosses
self.equipmentInfo = equipmentInfo
self.equivalentLengths = equivalentLengths
self.rooms = rooms
self.trunks = trunks
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let streetAddress: String?
public let city: String?
public let state: String?
public let zipCode: String?
public let sensibleHeatRatio: Double?
public init(
name: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
sensibleHeatRatio: Double? = nil
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
}
@@ -62,16 +140,22 @@ extension Project {
#if DEBUG
extension Project {
public static let mock = Self(
id: UUID(0),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: Date(),
updatedAt: Date()
)
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -6,8 +6,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let projectID: Project.ID
public let name: String
public let heatingLoad: Double
public let coolingLoad: CoolingLoad
public let coolingTotal: Double
public let coolingSensible: Double?
public let registerCount: Int
public let rectangularSizes: [RectangularSize]?
public let createdAt: Date
public let updatedAt: Date
@@ -16,8 +18,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingLoad: CoolingLoad,
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -25,8 +29,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.projectID = projectID
self.name = name
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
@@ -34,13 +40,12 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
extension Room {
// TODO: Maybe remove project ID, and make dependencies that retrieves current project id??
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let heatingLoad: Double
public let coolingTotal: Double
public let coolingSensible: Double
public let coolingSensible: Double?
public let registerCount: Int
public init(
@@ -48,7 +53,7 @@ extension Room {
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1
) {
self.projectID = projectID
@@ -59,6 +64,76 @@ extension Room {
self.registerCount = registerCount
}
}
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let heatingLoad: Double?
public let coolingTotal: Double?
public let coolingSensible: Double?
public let registerCount: Int?
public let rectangularSizes: [RectangularSize]?
public init(
name: String? = nil,
heatingLoad: Double? = nil,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int? = nil
) {
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = nil
}
public init(
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.heatingLoad = nil
self.coolingTotal = nil
self.coolingSensible = nil
self.registerCount = nil
self.rectangularSizes = rectangularSizes
}
}
}
extension Array where Element == Room {
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
}
}
}
#if DEBUG
@@ -70,7 +145,7 @@ extension Room {
projectID: UUID(0),
name: "Kitchen",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1234,
registerCount: 2,
createdAt: Date(),
updatedAt: Date()
@@ -80,7 +155,7 @@ extension Room {
projectID: UUID(1),
name: "Bedroom - 1",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1456,
registerCount: 1,
createdAt: Date(),
updatedAt: Date()
@@ -90,12 +165,92 @@ extension Room {
projectID: UUID(2),
name: "Family Room",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1673,
registerCount: 3,
createdAt: Date(),
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -45,6 +45,7 @@ extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create)
case delete(id: Project.ID)
case detail(id: Project.ID, route: DetailRoute)
case get(id: Project.ID)
case index
@@ -74,6 +75,36 @@ extension SiteRoute.Api {
Path { rootPath }
Method.get
}
Route(.case(Self.detail(id:route:))) {
Path {
rootPath
Project.ID.parser()
}
DetailRoute.router
}
}
}
}
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath
"completed"
}
Method.get
}
}
}
}

View File

@@ -1,4 +1,5 @@
import CasePathsCore
import FluentKit
import Foundation
@preconcurrency import URLRouting

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,114 @@
import Dependencies
import Foundation
// Represents the database model.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [RoomProxy]
public let height: Int?
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: TrunkType,
rooms: [RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
extension TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct Update: Codable, Equatable, Sendable {
public let type: TrunkType?
public let rooms: [Room.ID: [Int]]?
public let height: Int?
public let name: String?
public init(
type: TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}
#if DEBUG
extension TrunkSize {
public static func mock(projectID: Project.ID, rooms: [Room]) -> [Self] {
@Dependency(\.uuid) var uuid
let allRooms = rooms.reduce(into: [TrunkSize.RoomProxy]()) { array, room in
var registers = [Int]()
for n in 1...room.registerCount {
registers.append(n)
}
array.append(.init(room: room, registers: registers))
}
return [
.init(id: uuid(), projectID: projectID, type: .supply, rooms: allRooms),
.init(id: uuid(), projectID: projectID, type: .return, rooms: allRooms),
]
}
}
#endif

View File

@@ -1,23 +1,21 @@
import Dependencies
import Foundation
// FIX: Remove username.
public struct User: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let email: String
public let username: String
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
email: String,
username: String,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.username = username
self.email = email
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -27,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
extension User {
public struct Create: Codable, Equatable, Sendable {
public let username: String
public let email: String
public let password: String
public let confirmPassword: String
public init(
username: String,
email: String,
password: String,
confirmPassword: String
) {
self.username = username
self.email = email
self.password = password
self.confirmPassword = confirmPassword
@@ -48,10 +43,12 @@ extension User {
public struct Login: Codable, Equatable, Sendable {
public let email: String
public let password: String
public let next: String?
public init(email: String, password: String) {
public init(email: String, password: String, next: String? = nil) {
self.email = email
self.password = password
self.next = next
}
}
@@ -68,3 +65,15 @@ extension User {
}
}
}
#if DEBUG
extension User {
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(id: uuid(), email: "testy@example.com", createdAt: now, updatedAt: now)
}
}
#endif

View File

@@ -0,0 +1,140 @@
import Dependencies
import Foundation
extension User {
public struct Profile: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let userID: User.ID
public let firstName: String
public let lastName: String
public let companyName: String
public let streetAddress: String
public let city: String
public let state: String
public let zipCode: String
public let theme: Theme?
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
}
extension User.Profile {
public struct Create: Codable, Equatable, Sendable {
public let userID: User.ID
public let firstName: String
public let lastName: String
public let companyName: String
public let streetAddress: String
public let city: String
public let state: String
public let zipCode: String
public let theme: Theme?
public init(
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
public struct Update: Codable, Equatable, Sendable {
public let firstName: String?
public let lastName: String?
public let companyName: String?
public let streetAddress: String?
public let city: String?
public let state: String?
public let zipCode: String?
public let theme: Theme?
public init(
firstName: String? = nil,
lastName: String? = nil,
companyName: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
theme: Theme? = nil
) {
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
}
#if DEBUG
extension User.Profile {
public static func mock(userID: User.ID) -> Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
userID: userID,
firstName: "Testy",
lastName: "McTestface",
companyName: "Acme Co.",
streetAddress: "1234 Sesame St",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension DatabaseClient {
func calculateDuctSizes(
details: Project.Detail
) async throws -> (DuctSizes, DuctSizeSharedRequest) {
let (rooms, shared) = try await calculateRoomDuctSizes(details: details)
let (trunks, _) = try await calculateTrunkDuctSizes(details: details)
return (.init(rooms: rooms, trunks: trunks), shared)
}
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
let rooms = try await rooms.fetch(projectID)
return try await (
manualD.calculateDuctSizes(
rooms: rooms,
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared,
rooms
)
}
func calculateRoomDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let rooms = try await manualD.calculateRoomSizes(rooms: details.rooms, sharedRequest: shared)
return (rooms, shared)
}
func calculateRoomDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateRoomSizes(
rooms: rooms.fetch(projectID),
sharedRequest: shared
),
shared
)
}
func calculateTrunkDuctSizes(
details: Project.Detail
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try sharedDuctRequest(details: details)
let trunks = try await manualD.calculateTrunkSizes(
rooms: details.rooms,
trunks: details.trunks,
sharedRequest: shared
)
return (trunks, shared)
}
func calculateTrunkDuctSizes(
projectID: Project.ID
) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) {
@Dependency(\.manualD) var manualD
let shared = try await sharedDuctRequest(projectID)
return try await (
manualD.calculateTrunkSizes(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
sharedRequest: shared
),
shared
)
}
func sharedDuctRequest(details: Project.Detail) throws -> DuctSizeSharedRequest {
guard
let dfrResponse = designFrictionRate(
componentLosses: details.componentLosses,
equipmentInfo: details.equipmentInfo,
equivalentLengths: details.maxContainer
)
else {
throw ProjectClientError("Project not complete.")
}
guard let projectSHR = details.project.sensibleHeatRatio else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: projectSHR
)
}
func sharedDuctRequest(_ projectID: Project.ID) async throws -> DuctSizeSharedRequest {
guard let dfrResponse = try await designFrictionRate(projectID: projectID) else {
throw ProjectClientError("Project not complete.")
}
let ensuredTEL = try dfrResponse.ensureMaxContainer()
return try await .init(
equipmentInfo: dfrResponse.equipmentInfo,
maxSupplyLength: ensuredTEL.supply,
maxReturnLenght: ensuredTEL.return,
designFrictionRate: dfrResponse.designFrictionRate,
projectSHR: ensuredSHR(projectID)
)
}
// Fetches the project sensible heat ratio or throws an error if it's nil.
func ensuredSHR(_ projectID: Project.ID) async throws -> Double {
guard let projectSHR = try await projects.getSensibleHeatRatio(projectID) else {
throw ProjectClientError("Project sensible heat ratio not set.")
}
return projectSHR
}
// Internal container.
struct DesignFrictionRateResponse: Equatable, Sendable {
typealias EnsuredTEL = (supply: EffectiveLength, return: EffectiveLength)
let designFrictionRate: Double
let equipmentInfo: EquipmentInfo
let telMaxContainer: EffectiveLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL {
guard let maxSupplyLength = telMaxContainer.supply else {
throw ProjectClientError("Max supply TEL not found")
}
guard let maxReturnLength = telMaxContainer.return else {
throw ProjectClientError("Max supply TEL not found")
}
return (maxSupplyLength, maxReturnLength)
}
}
func designFrictionRate(
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: EffectiveLength.MaxContainer
) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.total,
componentLosses.count > 0
else { return nil }
let availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total
return .init(
designFrictionRate: (availableStaticPressure * 100) / tel,
equipmentInfo: equipmentInfo,
telMaxContainer: equivalentLengths
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> DesignFrictionRateResponse? {
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
}
return try await designFrictionRate(
componentLosses: componentLoss.fetch(projectID),
equipmentInfo: equipmentInfo,
equivalentLengths: effectiveLength.fetchMax(projectID)
)
}
}

View File

@@ -0,0 +1,223 @@
import Logging
import ManualDClient
import ManualDCore
struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo
let maxSupplyLength: EffectiveLength
let maxReturnLenght: EffectiveLength
let designFrictionRate: Double
let projectSHR: Double
}
// TODO: Remove Logger and use depedency logger.
extension ManualDClient {
func calculateDuctSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> DuctSizes {
try await .init(
rooms: calculateRoomSizes(
rooms: rooms,
sharedRequest: sharedRequest
),
trunks: calculateTrunkSizes(
rooms: rooms,
trunks: trunks,
sharedRequest: sharedRequest
)
)
}
func calculateRoomSizes(
rooms: [Room],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.RoomContainer] {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
)
for n in 1...room.registerCount {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.rectangularSize(
.init(round: sizes.finalSize, height: rectangularSize.height)
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
rectangularSize: rectangularSize,
width: rectangularWidth
)
)
)
}
}
return retval
}
func calculateTrunkSizes(
rooms: [Room],
trunks: [TrunkSize],
sharedRequest: DuctSizeSharedRequest,
logger: Logger? = nil
) async throws -> [DuctSizes.TrunkContainer] {
var retval = [DuctSizes.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(sharedRequest.equipmentInfo.coolingCFM)
let designCFM = DuctSizes.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: sharedRequest.designFrictionRate)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.rectangularSize(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
sizes: sizes,
height: trunk.height,
width: width
)
)
)
}
return retval
}
}
extension DuctSizes.SizeContainer {
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
height: Int?,
width: Int?
) {
self.init(
rectangularID: nil,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: height,
width: width
)
}
init(
designCFM: DuctSizes.DesignCFM,
sizes: ManualDClient.DuctSizeResponse,
rectangularSize: Room.RectangularSize?,
width: Int?
) {
self.init(
rectangularID: rectangularSize?.id,
designCFM: designCFM,
roundSize: sizes.calculatedSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: rectangularSize?.height,
width: width
)
}
}
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
extension TrunkSize.RoomProxy {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
// no longer exist.
private var actualRegisterCount: Int {
guard registers.count <= room.registerCount else {
return room.registerCount
}
return registers.count
}
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
extension TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}

View File

@@ -0,0 +1,55 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.total else {
return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer)
}
return try await .init(
componentLosses: details.componentLosses,
equivalentLengths: maxContainer,
frictionRate: frictionRate(
.init(
externalStaticPressure: details.equipmentInfo.staticPressure,
componentPressureLosses: details.componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
)
)
)
}
func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse {
@Dependency(\.database) var database
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
let equipmentInfo = try await database.equipment.fetch(projectID)
guard let staticPressure = equipmentInfo?.staticPressure else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
guard let totalEquivalentLength = lengths.total else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
return try await .init(
componentLosses: componentLosses,
equivalentLengths: lengths,
frictionRate: frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: database.componentLoss.fetch(projectID),
totalEffectiveLength: Int(totalEquivalentLength)
)
)
)
}
}

View File

@@ -0,0 +1,14 @@
import ManualDCore
extension Project.Detail {
var maxContainer: EffectiveLength.MaxContainer {
.init(
supply: equivalentLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first,
return: equivalentLengths.filter({ $0.type == .return })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first
)
}
}

View File

@@ -0,0 +1,110 @@
import DatabaseClient
import Dependencies
import FileClient
import Logging
import ManualDClient
import ManualDCore
import PdfClient
extension ProjectClient: DependencyKey {
public static var liveValue: Self {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient
@Dependency(\.fileClient) var fileClient
return .init(
calculateDuctSizes: { projectID in
try await database.calculateDuctSizes(projectID: projectID).0
},
calculateRoomDuctSizes: { projectID in
try await database.calculateRoomDuctSizes(projectID: projectID).0
},
calculateTrunkDuctSizes: { projectID in
try await database.calculateTrunkDuctSizes(projectID: projectID).0
},
createProject: { userID, request in
let project = try await database.projects.create(userID, request)
try await database.componentLoss.createDefaults(projectID: project.id)
return try await .init(
projectID: project.id,
rooms: database.rooms.fetch(project.id),
sensibleHeatRatio: database.projects.getSensibleHeatRatio(project.id),
completedSteps: database.projects.getCompletedSteps(project.id)
)
},
frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID)
},
generatePdf: { projectID in
let pdfResponse = try await pdfClient.generatePdf(
request: database.makePdfRequest(projectID)
)
let response = try await fileClient.streamFile(
pdfResponse.pdfPath,
{
try await fileClient.removeFile(pdfResponse.htmlPath)
try await fileClient.removeFile(pdfResponse.pdfPath)
}
)
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
}
)
}
}
extension DatabaseClient {
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD
guard let projectDetails = try await projects.detail(projectID) else {
throw ProjectClientError("Project not found. id: \(projectID)")
}
let (ductSizes, shared) = try await calculateDuctSizes(details: projectDetails)
let frictionRateResponse = try await manualD.frictionRate(details: projectDetails)
guard let frictionRate = frictionRateResponse.frictionRate else {
throw ProjectClientError("Friction rate not found. id: \(projectID)")
}
return .init(
details: projectDetails,
ductSizes: ductSizes,
shared: shared,
frictionRate: frictionRate
)
}
}
extension PdfClient.Request {
init(
details: Project.Detail,
ductSizes: DuctSizes,
shared: DuctSizeSharedRequest,
frictionRate: FrictionRate
) {
self.init(
project: details.project,
rooms: details.rooms,
componentLosses: details.componentLosses,
ductSizes: ductSizes,
equipmentInfo: details.equipmentInfo,
maxSupplyTEL: shared.maxSupplyLength,
maxReturnTEL: shared.maxReturnLenght,
frictionRate: frictionRate,
projectSHR: shared.projectSHR
)
}
}

View File

@@ -0,0 +1,28 @@
import Elementary
public struct Alert<Content: HTML>: HTML {
let inner: Content
public init(@HTMLBuilder content: () -> Content) {
self.inner = content()
}
public var body: some HTML<HTMLTag.div> {
div(.class("flex space-x-2")) {
SVG(.triangleAlert)
inner
}
}
}
extension Alert: Sendable where Content: Sendable {}
extension Alert where Content == p<HTMLText> {
public init(_ description: String) {
self.init {
p { description }
}
}
}

View File

@@ -0,0 +1,28 @@
import Elementary
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
let inner: Inner
public init(
@HTMLBuilder inner: () -> Inner
) {
self.inner = inner()
}
public var body: some HTML<HTMLTag.div> {
div(.class("badge badge-lg badge-outline")) {
inner
}
}
}
extension Badge where Inner == Number {
public init(number: Int) {
self.inner = Number(number)
}
public init(number: Double, digits: Int = 2) {
self.inner = Number(number, digits: digits)
}
}

View File

@@ -16,7 +16,7 @@ public struct SubmitButton: HTML, Sendable {
button(
.class(
"""
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
btn btn-secondary
"""
),
.type(type)
@@ -53,11 +53,11 @@ public struct CancelButton: HTML, Sendable {
}
public struct EditButton: HTML, Sendable {
let title: String
let title: String?
let type: HTMLAttribute<HTMLTag.button>.ButtonType
public init(
title: String = "Edit",
title: String? = nil,
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
) {
self.title = title
@@ -65,16 +65,11 @@ public struct EditButton: HTML, Sendable {
}
public var body: some HTML<HTMLTag.button> {
button(
.class(
"""
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
"""
),
.type(type)
) {
button(.class("btn"), .type(type)) {
div(.class("flex")) {
span(.class("pe-2")) { title }
if let title {
span(.class("pe-2")) { title }
}
SVG(.squarePen)
}
}
@@ -86,6 +81,22 @@ public struct PlusButton: HTML, Sendable {
public init() {}
public var body: some HTML<HTMLTag.button> {
button(.type(.button)) { SVG(.circlePlus) }
button(
.type(.button),
.class("btn")
) { SVG(.circlePlus) }
}
}
public struct TrashButton: HTML, Sendable {
public init() {}
public var body: some HTML<HTMLTag.button> {
button(
.type(.button),
.class("btn btn-error")
) {
SVG(.trash)
}
}
}

View File

@@ -0,0 +1,20 @@
import Elementary
import Foundation
public struct DateView: HTML, Sendable {
let date: Date
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "MM/dd/yyyy"
return formatter
}
public init(_ date: Date) {
self.date = date
}
public var body: some HTML<HTMLTag.span> {
span { formatter.string(from: date) }
}
}

View File

@@ -1,4 +1,5 @@
import Elementary
import Foundation
import ManualDCore
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
@@ -7,3 +8,49 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
href(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.form {
public static func action(route: SiteRoute.View) -> Self {
action(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ string: String?) -> Self {
value(string ?? "")
}
public static func value(_ int: Int?) -> Self {
value(int == nil ? "" : "\(int!)")
}
public static func value(_ double: Double?) -> Self {
value(double == nil ? "" : "\(double!)")
}
public static func value(_ uuid: UUID?) -> Self {
value(uuid?.uuidString ?? "")
}
}
extension HTMLAttribute where Tag == HTMLTag.button {
public static func showModal(id: String) -> Self {
.on(.click, "\(id).showModal()")
}
}
extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline"))
}
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
attributes(.class("hidden"), when: shouldHide)
}
public func bold(when shouldBeBold: Bool = true) -> _AttributedElement<Self> {
attributes(.class("font-bold"), when: shouldBeBold)
}
}

View File

@@ -23,8 +23,15 @@ extension HTMLAttribute.hx {
put(SiteRoute.View.router.path(for: route))
}
// @Sendable
// static func delete(route: SiteRoute.Api) -> HTMLAttribute {
// delete(SiteRoute.Api.router.path(for: route))
// }
@Sendable
public static func delete(route: SiteRoute.View) -> HTMLAttribute {
delete(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute.hx {
@Sendable
public static func indicator() -> HTMLAttribute {
indicator(".htmx-indicator")
}
}

View File

@@ -1,5 +1,6 @@
import Elementary
// TODO: Remove, using svg's.
public struct Icon: HTML, Sendable {
let icon: String

View File

@@ -1,5 +1,26 @@
import Elementary
public struct LabeledInput: HTML, Sendable {
let labelText: String
let inputAttributes: [HTMLAttribute<HTMLTag.input>]
public init(
_ label: String,
_ attributes: HTMLAttribute<HTMLTag.input>...
) {
self.labelText = label
self.inputAttributes = attributes
}
public var body: some HTML<HTMLTag.label> {
label(.class("input w-full")) {
span(.class("label")) { labelText }
input(attributes: inputAttributes)
}
}
}
public struct Input: HTML, Sendable {
let id: String?

View File

@@ -13,7 +13,7 @@ public struct Label: HTML, Sendable {
}
public var body: some HTML<HTMLTag.span> {
span(.class("text-xl text-gray-400 font-bold")) {
span(.class("text-lg label font-bold")) {
title
}
}

View File

@@ -0,0 +1,72 @@
import Elementary
public struct LabeledContent<Label: HTML, Content: HTML>: HTML {
let label: @Sendable () -> Label
let content: @Sendable () -> Content
let position: LabelPosition
public init(
position: LabelPosition = .default,
@HTMLBuilder label: @escaping @Sendable () -> Label,
@HTMLBuilder content: @escaping @Sendable () -> Content
) {
self.position = position
self.label = label
self.content = content
}
public var body: some HTML<HTMLTag.div> {
div {
switch position {
case .leading:
label()
content()
case .trailing:
content()
label()
case .top:
label()
content()
case .bottom:
content()
label()
}
}
.attributes(.class("flex space-x-4"), when: position.isHorizontal)
.attributes(.class("space-y-4"), when: position.isVertical)
}
}
// TODO: Merge / use TooltipPosition
public enum LabelPosition: String, CaseIterable, Equatable, Sendable {
case leading
case trailing
case top
case bottom
var isHorizontal: Bool {
self == .leading || self == .trailing
}
var isVertical: Bool {
self == .top || self == .bottom
}
public static let `default` = Self.leading
}
extension LabeledContent: Sendable where Label: Sendable, Content: Sendable {}
extension LabeledContent where Label == Styleguide.Label {
public init(
_ label: String, position: LabelPosition = .default,
@HTMLBuilder content: @escaping @Sendable () -> Content
) {
self.init(
position: position,
label: { Label(label) },
content: content
)
}
}

View File

@@ -2,36 +2,37 @@ import Elementary
public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
let closeButton: Bool
let dismiss: Bool
let id: String
let inner: T
public init(
id: String,
closeButton: Bool = true,
dismiss: Bool,
@HTMLBuilder inner: () -> T
) {
self.closeButton = closeButton
self.dismiss = dismiss
self.id = id
self.inner = inner()
}
public var body: some HTML {
if dismiss {
div(.id(id)) {}
} else {
div(
.id(id),
.class(
"""
fixed top-40 left-[25vw] w-1/2 z-50 text-gray-800
bg-gray-200 border border-gray-400
rounded-lg shadow-lg mx-10
"""
)
) {
public var body: some HTML<HTMLTag.dialog> {
dialog(.id(id), .class("modal")) {
div(.class("modal-box")) {
if closeButton {
button(
.class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
.on(.click, "\(id).close()")
) {
SVG(.close)
}
}
inner
}
}
.attributes(.class("modal-open"), when: dismiss == false)
}
}

View File

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

View File

@@ -0,0 +1,40 @@
import Elementary
public struct PageTitleRow<Content: HTML>: HTML, Sendable where Content: Sendable {
let inner: Content
public init(@HTMLBuilder content: () -> Content) {
self.inner = content()
}
public var body: some HTML<HTMLTag.div> {
div(
.class(
"""
flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
p-6 w-full
"""
)
) {
inner
}
}
}
public struct PageTitle: HTML, Sendable {
let title: String
public init(_ title: String) {
self.title = title
}
public init(_ title: () -> String) {
self.title = title()
}
public var body: some HTML<HTMLTag.h1> {
h1(.class("text-3xl font-bold")) { title }
}
}

View File

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

View File

@@ -15,23 +15,66 @@ public struct SVG: HTML, Sendable {
extension SVG {
public enum Key: Sendable {
case badgeCheck
case ban
case chevronDown
case chevronRight
case chevronsLeft
case circlePlus
case circleUser
case close
case doorClosed
case email
case fan
case key
case mapPin
case rulerDimensionLine
case sidebarToggle
case squareFunction
case squarePen
case trash
case triangleAlert
case user
case wind
var svg: String {
switch self {
case .badgeCheck:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>
"""
case .ban:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
"""
case .chevronDown:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
"""
case .chevronRight:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
"""
case .chevronsLeft:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>
"""
case .circlePlus:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
"""
case .circleUser:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
"""
case .close:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
"""
case .doorClosed:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg>
"""
case .email:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -47,6 +90,10 @@ extension SVG {
</g>
</svg>
"""
case .fan:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg>
"""
case .key:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -64,9 +111,34 @@ extension SVG {
</g>
</svg>
"""
case .mapPin:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
"""
case .rulerDimensionLine:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg>
"""
case .sidebarToggle:
return """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
"""
case .squareFunction:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg>
"""
case .squarePen:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
"""
case .trash:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
"""
case .triangleAlert:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
"""
case .user:
return """
@@ -83,6 +155,10 @@ extension SVG {
</g>
</svg>
"""
case .wind:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>
"""
}
}
}

View File

@@ -0,0 +1,51 @@
import Elementary
extension HTML {
public func tooltip(
_ tip: String,
position: TooltipPosition = .default
) -> Tooltip<Self> {
Tooltip(tip, position: position) {
self
}
}
}
public struct Tooltip<Inner: HTML>: HTML {
let tooltip: String
let position: TooltipPosition
let inner: Inner
public init(
_ tooltip: String,
position: TooltipPosition = .default,
@HTMLBuilder inner: () -> Inner
) {
self.tooltip = tooltip
self.position = position
self.inner = inner()
}
public var body: some HTML<HTMLTag.div> {
div(
.class("tooltip tooltip-\(position.rawValue)"),
.data("tip", value: tooltip)
) {
inner
}
}
}
extension Tooltip: Sendable where Inner: Sendable {}
public enum TooltipPosition: String, CaseIterable, Sendable {
public static let `default` = Self.left
case bottom
case left
case right
case top
}

View File

@@ -0,0 +1,60 @@
import DatabaseClient
import ManualDCore
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
func validate() throws(ValidationError) {
guard groupGroups.count == groupLengths.count,
groupGroups.count == groupLetters.count,
groupGroups.count == groupQuantities.count
else {
throw ValidationError("Equivalent length form group counts are not equal.")
}
}
var groups: [EffectiveLength.Group] {
var groups = [EffectiveLength.Group]()
for (n, group) in groupGroups.enumerated() {
groups.append(
.init(
group: group,
letter: groupLetters[n],
value: Double(groupLengths[n]),
quantity: groupQuantities[n]
)
)
}
return groups
}
}
extension EffectiveLength.Create {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID
) {
self.init(
projectID: projectID,
name: form.name,
type: form.type,
straightLengths: form.straightLengths,
groups: form.groups
)
}
}
extension EffectiveLength.Update {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID
) throws {
self.init(
name: form.name,
type: form.type,
straightLengths: form.straightLengths,
groups: form.groups
)
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
extension String {
func appendingPath(_ string: String) -> Self {
guard string.starts(with: "/") else {
return self.appending("/\(string)")
}
return self.appending(string)
}
func appendingPath(_ id: UUID?) -> Self {
guard let id else { return self }
return appendingPath(id.uuidString)
}
func appendingPath(_ id: UUID) -> Self {
return appendingPath(id.uuidString)
}
var idString: Self {
replacing("-", with: "")
.replacing(" ", with: "")
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import Logging
import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> TrunkSize.Create {
try .init(
projectID: projectID,
type: type,
rooms: makeRooms(logger: logger),
height: height,
name: name
)
}
func toUpdate(logger: Logger? = nil) throws -> TrunkSize.Update {
try .init(
type: type,
rooms: makeRooms(logger: logger),
height: height,
name: name
)
}
func makeRooms(logger: Logger?) throws -> [Room.ID: [Int]] {
var retval = [Room.ID: [Int]]()
for room in rooms {
let split = room.split(separator: "_")
guard let idString = split.first,
let id = UUID(uuidString: String(idString))
else {
logger?.error("Could not parse id from: \(room)")
throw RoomError()
}
guard let registerString = split.last,
let register = Int(registerString)
else {
logger?.error("Could not register number from: \(room)")
throw RoomError()
}
if var currRegisters = retval[id] {
currRegisters.append(register)
retval[id] = currRegisters
} else {
retval[id] = [register]
}
}
return retval
}
}
struct RoomError: Error {}

View File

@@ -0,0 +1,7 @@
import Foundation
extension UUID {
var idString: String {
uuidString.idString
}
}

View File

@@ -1,3 +1,4 @@
import AuthClient
import Dependencies
import DependenciesMacros
import Elementary
@@ -15,10 +16,6 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient
public struct ViewController: Sendable {
public typealias AuthenticateHandler = @Sendable (User) -> Void
public typealias CurrentUserHandler = @Sendable () throws -> User
public var view: @Sendable (Request) async throws -> AnySendableHTML
}
@@ -29,26 +26,17 @@ extension ViewController {
public let route: SiteRoute.View
public let isHtmxRequest: Bool
public let logger: Logger
public let authenticateUser: AuthenticateHandler
public let currentUser: CurrentUserHandler
public init(
route: SiteRoute.View,
isHtmxRequest: Bool,
logger: Logger,
authenticateUser: @escaping AuthenticateHandler,
currentUser: @escaping CurrentUserHandler
logger: Logger
) {
self.route = route
self.isHtmxRequest = isHtmxRequest
self.logger = logger
self.authenticateUser = authenticateUser
self.currentUser = currentUser
}
func authenticate(_ user: User) {
self.authenticateUser(user)
}
}
}
@@ -58,7 +46,30 @@ extension ViewController: DependencyKey {
// FIX: Fix.
public static let liveValue = Self(
view: { request in
try await request.render()
await request.render()
}
)
}
extension ViewController.Request {
func currentUser() throws -> User {
@Dependency(\.authClient.currentUser) var currentUser
return try currentUser()
}
func authenticate(
_ login: User.Login
) async throws -> User {
@Dependency(\.authClient) var auth
return try await auth.login(login)
}
@discardableResult
func createAndAuthenticate(
_ signup: User.Create
) async throws -> User {
@Dependency(\.authClient) var auth
return try await auth.createAndLogin(signup)
}
}

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