57 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
143 changed files with 12838 additions and 5265 deletions

View File

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

4
.gitignore vendored
View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,9 @@ public struct DatabaseClient: Sendable {
public var equipment: Equipment
public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient
// public var rectangularDuct: RectangularDuct
public var users: Users
public var userProfile: UserProfile
public var trunkSizes: TrunkSizes
}
extension DatabaseClient: TestDependencyKey {
@@ -30,8 +31,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
// rectangularDuct: .testValue,
users: .testValue
users: .testValue,
userProfile: .testValue,
trunkSizes: .testValue
)
public static func live(database: any Database) -> Self {
@@ -42,8 +44,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database),
componentLoss: .live(database: database),
effectiveLength: .live(database: database),
// rectangularDuct: .live(database: database),
users: .live(database: database)
users: .live(database: database),
userProfile: .live(database: database),
trunkSizes: .live(database: database)
)
}
}
@@ -52,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()
}
}
}
@@ -66,11 +73,12 @@ extension DatabaseClient.Migrations: DependencyKey {
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
// DuctSizing.RectangularDuct.Migrate(),
TrunkSize.Migrate(),
]
}
)

View File

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

View File

@@ -10,10 +10,11 @@ extension DatabaseClient {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct.ID) async throws -> Room
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
}
@@ -67,6 +68,19 @@ extension DatabaseClient.Rooms: TestDependencyKey {
try await model.save(on: database)
}
return try model.toDTO()
},
updateRectangularSize: { id, size in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
var rectangularSizes = model.rectangularSizes ?? []
rectangularSizes.removeAll {
$0.id == size.id
}
rectangularSizes.append(size)
model.rectangularSizes = rectangularSizes
try await model.save(on: database)
return try model.toDTO()
}
)
}
@@ -189,7 +203,7 @@ final class RoomModel: Model, @unchecked Sendable {
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]?
var rectangularSizes: [Room.RectangularSize]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -209,7 +223,7 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [Room.RectangularSize]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID

View File

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

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

View File

@@ -0,0 +1,129 @@
import Dependencies
import DependenciesMacros
import Logging
import ManualDCore
extension DependencyValues {
public var manualD: ManualDClient {
get { self[ManualDClient.self] }
set { self[ManualDClient.self] = newValue }
}
}
/// Performs manual-d duct sizing calculations.
///
///
@DependencyClient
public struct ManualDClient: Sendable {
public var ductSize: @Sendable (DuctSizeRequest) async throws -> DuctSizeResponse
public var frictionRate: @Sendable (FrictionRateRequest) async throws -> FrictionRate
public var totalEquivalentLength: @Sendable (TotalEquivalentLengthRequest) async throws -> Int
public var rectangularSize:
@Sendable (RectangularSizeRequest) async throws -> RectangularSizeResponse
}
extension ManualDClient: TestDependencyKey {
public static let testValue = Self()
}
extension ManualDClient {
public struct DuctSizeRequest: Codable, Equatable, Sendable {
public let designCFM: Int
public let frictionRate: Double
public init(
designCFM: Int,
frictionRate: Double
) {
self.designCFM = designCFM
self.frictionRate = frictionRate
}
}
public struct DuctSizeResponse: Codable, Equatable, Sendable {
public let calculatedSize: Double
public let finalSize: Int
public let flexSize: Int
public let velocity: Int
public init(
calculatedSize: Double,
finalSize: Int,
flexSize: Int,
velocity: Int
) {
self.calculatedSize = calculatedSize
self.finalSize = finalSize
self.flexSize = flexSize
self.velocity = velocity
}
}
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure
self.componentPressureLosses = componentPressureLosses
self.totalEffectiveLength = totalEffectiveLength
}
}
public struct FrictionRateResponse: Codable, Equatable, Sendable {
public let availableStaticPressure: Double
public let frictionRate: Double
public init(availableStaticPressure: Double, frictionRate: Double) {
self.availableStaticPressure = availableStaticPressure
self.frictionRate = frictionRate
}
}
public struct TotalEquivalentLengthRequest: Codable, Equatable, Sendable {
public let trunkLengths: [Int]
public let runoutLengths: [Int]
public let effectiveLengthGroups: [EffectiveLengthGroup]
public init(
trunkLengths: [Int],
runoutLengths: [Int],
effectiveLengthGroups: [EffectiveLengthGroup]
) {
self.trunkLengths = trunkLengths
self.runoutLengths = runoutLengths
self.effectiveLengthGroups = effectiveLengthGroups
}
}
public struct RectangularSizeRequest: Codable, Equatable, Sendable {
public let roundSize: Int
public let height: Int
public init(round roundSize: Int, height: Int) {
self.roundSize = roundSize
self.height = height
}
}
public struct RectangularSizeResponse: Codable, Equatable, Sendable {
public let height: Int
public let width: Int
public init(height: Int, width: Int) {
self.height = height
self.width = width
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,93 +0,0 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct RoomContainer: Codable, Equatable, Sendable {
public let registerID: String
public let roomID: Room.ID
public let roomName: String
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let rectangularSize: RectangularDuct?
public let rectangularWidth: Int?
public init(
registerID: String,
roomID: Room.ID,
roomName: String,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.registerID = registerID
self.roomID = roomID
self.roomName = roomName
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,17 +66,50 @@ extension Project {
public struct CompletedSteps: Codable, Equatable, Sendable {
public let equipmentInfo: Bool
public let rooms: Bool
public let equivalentLength: Bool
public let frictionRate: Bool
public init(rooms: Bool, equivalentLength: Bool, frictionRate: Bool) {
public init(
equipmentInfo: Bool,
rooms: Bool,
equivalentLength: Bool,
frictionRate: Bool
) {
self.equipmentInfo = equipmentInfo
self.rooms = rooms
self.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?
@@ -107,16 +140,22 @@ extension Project {
#if DEBUG
extension Project {
public static let mock = Self(
id: UUID(0),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: Date(),
updatedAt: Date()
)
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(
id: uuid(),
name: "Testy McTestface",
streetAddress: "1234 Sesame Street",
city: "Monroe",
state: "OH",
zipCode: "55555",
createdAt: now,
updatedAt: now
)
}
}
#endif

View File

@@ -9,7 +9,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let coolingTotal: Double
public let coolingSensible: Double?
public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public let createdAt: Date
public let updatedAt: Date
@@ -21,7 +21,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
rectangularSizes: [RectangularSize]? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -65,13 +65,30 @@ extension Room {
}
}
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let heatingLoad: Double?
public let coolingTotal: Double?
public let coolingSensible: Double?
public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let rectangularSizes: [RectangularSize]?
public init(
name: String? = nil,
@@ -89,7 +106,7 @@ extension Room {
}
public init(
rectangularSizes: [DuctSizing.RectangularDuct]
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.heatingLoad = nil
@@ -154,6 +171,86 @@ extension Array where Element == Room {
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -88,11 +88,16 @@ extension SiteRoute.Api {
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath

View File

@@ -11,6 +11,7 @@ extension SiteRoute {
case login(LoginRoute)
case signup(SignupRoute)
case project(ProjectRoute)
case user(UserRoute)
//FIX: Remove.
case test
@@ -28,6 +29,9 @@ extension SiteRoute {
Route(.case(Self.project)) {
SiteRoute.View.ProjectRoute.router
}
Route(.case(Self.user)) {
SiteRoute.View.UserRoute.router
}
}
}
}
@@ -37,7 +41,6 @@ extension SiteRoute.View {
case create(Project.Create)
case delete(id: Project.ID)
case detail(Project.ID, DetailRoute)
case form(id: Project.ID? = nil, dismiss: Bool = false)
case index
case page(PageRequest)
case update(Project.ID, Project.Update)
@@ -82,19 +85,6 @@ extension SiteRoute.View {
}
DetailRoute.router
}
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Optionally {
Field("id", default: nil) { Project.ID.parser() }
}
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.index)) {
Path { rootPath }
Method.get
@@ -150,22 +140,18 @@ extension SiteRoute.View {
extension SiteRoute.View.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index(tab: Tab = .default)
case index
case componentLoss(ComponentLossRoute)
case ductSizing(DuctSizingRoute)
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute)
static let router = OneOf {
Route(.case(Self.index)) {
Method.get
Query {
Field("tab", default: Tab.default) {
Tab.parser()
}
}
}
Route(.case(Self.componentLoss)) {
ComponentLossRoute.router
@@ -182,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) {
FrictionRateRoute.router
}
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) {
RoomRoute.router
}
@@ -189,18 +179,16 @@ extension SiteRoute.View.ProjectRoute {
public enum Tab: String, CaseIterable, Equatable, Sendable {
case project
case equipment
case rooms
case equivalentLength
case frictionRate
case ductSizing
public static var `default`: Self { .rooms }
}
}
public enum RoomRoute: Equatable, Sendable {
case delete(id: Room.ID)
case form(id: Room.ID? = nil, dismiss: Bool = false)
case index
case submit(Room.Create)
case update(Room.ID, Room.Update)
@@ -216,19 +204,6 @@ extension SiteRoute.View.ProjectRoute {
}
Method.delete
}
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Optionally {
Field("id", default: nil) { Room.ID.parser() }
}
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.index)) {
Path {
rootPath
@@ -358,8 +333,6 @@ extension SiteRoute.View.ProjectRoute {
public enum FrictionRateRoute: Equatable, Sendable {
case index
// TODO: Remove form or move equipment / component losses routes here.
case form(FormType, dismiss: Bool = false)
static let rootPath = "friction-rate"
@@ -368,28 +341,11 @@ extension SiteRoute.View.ProjectRoute {
Path { rootPath }
Method.get
}
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("type") { FormType.parser() }
Field("dismiss", default: false) { Bool.parser() }
}
}
}
public enum FormType: String, CaseIterable, Codable, Equatable, Sendable {
case equipmentInfo
case componentPressureLoss
}
}
public enum EquipmentInfoRoute: Equatable, Sendable {
case index
case form(dismiss: Bool)
case submit(EquipmentInfo.Create)
case update(EquipmentInfo.ID, EquipmentInfo.Update)
@@ -400,16 +356,6 @@ extension SiteRoute.View.ProjectRoute {
Path { rootPath }
Method.get
}
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("dismiss", default: true) { Bool.parser() }
}
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
@@ -450,7 +396,6 @@ extension SiteRoute.View.ProjectRoute {
public enum EquivalentLengthRoute: Equatable, Sendable {
case delete(id: EffectiveLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
case form(dismiss: Bool = false)
case index
case submit(FormStep)
case update(EffectiveLength.ID, StepThree)
@@ -469,16 +414,6 @@ extension SiteRoute.View.ProjectRoute {
Path { rootPath }
Method.get
}
Route(.case(Self.form(dismiss:))) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.field)) {
Path {
rootPath
@@ -677,9 +612,11 @@ extension SiteRoute.View.ProjectRoute {
public enum DuctSizingRoute: Equatable, Sendable {
case index
case deleteRectangularSize(Room.ID, DuctSizing.RectangularDuct.ID)
case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
case roomRectangularForm(Room.ID, RoomRectangularForm)
case trunk(TrunkRoute)
public static let roomPath = "room"
static let rootPath = "duct-sizing"
static let router = OneOf {
@@ -690,25 +627,27 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.deleteRectangularSize)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
}
Route(.case(Self.roomRectangularForm)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.post
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
Field("id") { Room.RectangularSize.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
@@ -716,12 +655,125 @@ extension SiteRoute.View.ProjectRoute {
.map(.memberwise(RoomRectangularForm.init))
}
}
Route(.case(Self.trunk)) {
Path { rootPath }
TrunkRoute.router
}
}
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: Room.RectangularSize.ID
public let register: Int
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(TrunkSize.ID)
case submit(TrunkSizeForm)
case update(TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
static let router = OneOf {
Route(.case(Self.delete)) {
Path {
rootPath
TrunkSize.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path {
rootPath
}
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
}
}
public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let id: Room.RectangularSize.ID?
public let register: Int
public let height: Int
public init(
id: Room.RectangularSize.ID? = nil,
register: Int,
height: Int
) {
self.id = id
self.register = register
self.height = height
}
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
public init(
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil,
rooms: [String]
) {
self.projectID = projectID
self.type = type
self.height = height
self.name = name
self.rooms = rooms
}
}
}
}
@@ -770,6 +822,7 @@ extension SiteRoute.View {
public enum SignupRoute: Equatable, Sendable {
case index
case submit(User.Create)
case submitProfile(User.Profile.Create)
static let rootPath = "signup"
@@ -783,7 +836,6 @@ extension SiteRoute.View {
Method.post
Body {
FormData {
Field("username", .string)
Field("email", .string)
Field("password", .string)
Field("confirmPassword", .string)
@@ -791,6 +843,114 @@ extension SiteRoute.View {
.map(.memberwise(User.Create.init))
}
}
Route(.case(Self.submitProfile)) {
Path {
rootPath
"profile"
}
Method.post
Body {
FormData {
Field("userID") { User.ID.parser() }
Field("firstName", .string)
Field("lastName", .string)
Field("companyName", .string)
Field("streetAddress", .string)
Field("city", .string)
Field("state", .string)
Field("zipCode", .string)
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Create.init))
}
}
}
}
}
extension SiteRoute.View {
public enum UserRoute: Equatable, Sendable {
case profile(Profile)
static let router = OneOf {
Route(.case(Self.profile)) {
Profile.router
}
}
}
}
extension SiteRoute.View.UserRoute {
public enum Profile: Equatable, Sendable {
case index
case submit(User.Profile.Create)
case update(User.Profile.ID, User.Profile.Update)
static let rootPath = "profile"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("userID") { User.ID.parser() }
Field("firstName", .string)
Field("lastName", .string)
Field("companyName", .string)
Field("streetAddress", .string)
Field("city", .string)
Field("state", .string)
Field("zipCode", .string)
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Create.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
User.Profile.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("firstName", .string)
}
Optionally {
Field("lastName", .string)
}
Optionally {
Field("companyName", .string)
}
Optionally {
Field("streetAddress", .string)
}
Optionally {
Field("city", .string)
}
Optionally {
Field("state", .string)
}
Optionally {
Field("zipCode", .string)
}
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Update.init))
}
}
}
}
}

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

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

View File

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

View File

@@ -40,3 +40,17 @@ extension HTMLAttribute where Tag == HTMLTag.button {
.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

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

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

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

View File

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

View File

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

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

View File

@@ -17,11 +17,15 @@ 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
@@ -29,6 +33,7 @@ extension SVG {
case squareFunction
case squarePen
case trash
case triangleAlert
case user
case wind
@@ -42,14 +47,26 @@ extension SVG {
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-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>
@@ -73,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">
@@ -115,6 +136,10 @@ extension SVG {
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
"""
case .triangleAlert:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
"""
case .user:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">

View File

@@ -1,6 +1,18 @@
import Elementary
public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
extension HTML {
public func tooltip(
_ tip: String,
position: TooltipPosition = .default
) -> Tooltip<Self> {
Tooltip(tip, position: position) {
self
}
}
}
public struct Tooltip<Inner: HTML>: HTML {
let tooltip: String
let position: TooltipPosition
@@ -26,6 +38,8 @@ public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
}
}
extension Tooltip: Sendable where Inner: Sendable {}
public enum TooltipPosition: String, CaseIterable, Sendable {
public static let `default` = Self.left

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import PdfClient
import ProjectClient
import Styleguide
extension ViewController.Request {
@@ -10,16 +12,32 @@ extension ViewController.Request {
func render() async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
@Dependency(\.pdfClient) var pdfClient
switch route {
case .test:
return view {
TestPage()
}
// let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
// return await view {
// await ResultView {
//
// // return (
// // try await database.projects.getCompletedSteps(projectID),
// // try await projectClient.calculateDuctSizes(projectID)
// // )
// return try await pdfClient.html(.mock())
// } onSuccess: {
// $0
// // TestPage()
// // TestPage(trunks: result.trunks, rooms: result.rooms)
// }
// }
// return try! await pdfClient.html(.mock())
return EmptyHTML()
case .login(let route):
switch route {
case .index(let next):
return view {
return await view {
LoginForm(next: next)
}
case .submit(let login):
@@ -35,52 +53,68 @@ extension ViewController.Request {
case .signup(let route):
switch route {
case .index:
return view {
return await view {
LoginForm(style: .signup)
}
case .submit(let request):
// Create a new user and log them in.
return await view {
await ResultView {
let user = try await createAndAuthenticate(request)
try await createAndAuthenticate(request)
} onSuccess: { user in
MainPage {
UserProfileForm(userID: user.id, profile: nil, dismiss: false, signup: true)
}
}
}
case .submitProfile(let profile):
return await view {
await ResultView {
_ = try await database.userProfile.create(profile)
let userID = profile.userID
// let user = try currentUser()
return (
user.id,
try await database.projects.fetch(user.id, .init(page: 1, per: 25))
userID,
try await database.projects.fetch(userID, .init(page: 1, per: 25)),
profile.theme
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
} onSuccess: { (userID, projects, theme) in
MainPage(displayFooter: true, theme: theme) {
ProjectsTable(userID: userID, projects: projects)
}
}
}
}
case .project(let route):
return await route.renderView(on: self)
default:
// FIX: FIX
return _render(isHtmxRequest: false) {
div { "Fix me!" }
}
}
}
func view<C: HTML>(
@HTMLBuilder inner: () -> C
) -> AnySendableHTML where C: Sendable {
_render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) {
inner()
case .user(let route):
return await route.renderView(on: self)
}
}
func view<C: HTML>(
@HTMLBuilder inner: () async -> C
) async -> AnySendableHTML where C: Sendable {
await _render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) {
await inner()
let inner = await inner()
let theme = await self.theme
return MainPage(displayFooter: displayFooter, theme: theme) {
inner
}
}
var showSidebar: Bool {
var theme: Theme? {
get async {
@Dependency(\.database) var database
guard let user = try? currentUser() else { return nil }
return try? await database.userProfile.fetch(user.id)?.theme
}
}
var displayFooter: Bool {
switch route {
case .login, .signup, .project(.page):
case .login, .signup:
return false
default:
return true
@@ -92,7 +126,7 @@ extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database
// let user = try request.currentUser()
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -101,7 +135,7 @@ extension SiteRoute.View.ProjectRoute {
let user = try request.currentUser()
return try await (
user.id,
database.projects.fetchPage(userID: user.id)
database.projects.fetch(user.id, .first)
)
} onSuccess: { (userID, projects) in
@@ -119,48 +153,36 @@ extension SiteRoute.View.ProjectRoute {
ProjectsTable(userID: userID, projects: projects)
}
case .form(let id, let dismiss):
return await ResultView {
var project: Project? = nil
if let id, dismiss == false {
project = try await database.projects.get(id)
}
return project
} onSuccess: { project in
ProjectForm(dismiss: dismiss, project: project)
}
case .create(let form):
return await ResultView {
let user = try request.currentUser()
let project = try await database.projects.create(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id)
return project.id
} onSuccess: { projectID in
ProjectView(projectID: projectID, activeTab: .rooms)
return await request.view {
await ResultView {
let user = try request.currentUser()
return try await projectClient.createProject(user.id, form)
} onSuccess: { response in
ProjectView(
projectID: response.projectID,
activeTab: .rooms,
completedSteps: response.completedSteps
) {
RoomsView(rooms: response.rooms, sensibleHeatRatio: response.sensibleHeatRatio)
}
}
}
case .delete(let id):
return await ResultView {
try await database.projects.delete(id)
} onSuccess: {
EmptyHTML()
}
case .update(let id, let form):
return await ResultView {
try await database.projects.update(id, form).id
} onSuccess: { projectID in
return ProjectView(projectID: projectID, activeTab: .project)
return await projectView(on: request, projectID: id) {
_ = try await database.projects.update(id, form)
}
case .detail(let projectID, let route):
switch route {
case .index(let tab):
return request.view {
ProjectView(projectID: projectID, activeTab: tab)
}
case .index:
return await projectView(on: request, projectID: projectID)
case .componentLoss(let route):
return await route.renderView(on: request, projectID: projectID)
case .ductSizing(let route):
@@ -170,7 +192,13 @@ extension SiteRoute.View.ProjectRoute {
case .equivalentLength(let route):
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route):
return route.renderView(on: request, projectID: projectID)
return await route.renderView(on: request, projectID: projectID)
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
// return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route):
return await route.renderView(on: request, projectID: projectID)
}
@@ -178,6 +206,31 @@ extension SiteRoute.View.ProjectRoute {
}
func projectView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
guard let project = try await database.projects.get(projectID) else {
throw NotFoundError()
}
return (
try await database.projects.getCompletedSteps(project.id),
project
)
} onSuccess: { (steps, project) in
ProjectView(projectID: projectID, activeTab: .project, completedSteps: steps) {
ProjectDetail(project: project)
}
}
}
}
}
extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
@@ -189,28 +242,38 @@ extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
switch self {
case .index:
return await ResultView {
try await database.equipment.fetch(projectID)
} onSuccess: { equipment in
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
}
case .form(let dismiss):
return await ResultView {
try await database.equipment.fetch(projectID)
} onSuccess: { equipment in
EquipmentInfoForm(dismiss: dismiss, projectID: projectID, equipmentInfo: equipment)
}
return await equipmentView(on: request, projectID: projectID)
case .submit(let form):
return await ResultView {
try await database.equipment.create(form)
} onSuccess: { equipment in
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
return await equipmentView(on: request, projectID: projectID) {
_ = try await database.equipment.create(form)
}
case .update(let id, let updates):
return await ResultView {
try await database.equipment.update(id, updates)
} onSuccess: { equipment in
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
return await equipmentView(on: request, projectID: projectID) {
_ = try await database.equipment.update(id, updates)
}
}
}
func equipmentView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.equipment.fetch(projectID)
)
} onSuccess: { (steps, equipment) in
ProjectView(projectID: projectID, activeTab: .equipment, completedSteps: steps) {
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
}
}
}
}
@@ -230,49 +293,47 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
try await database.rooms.delete(id)
}
case .form(let id, let dismiss):
return await ResultView {
var room: Room? = nil
if let id, dismiss == false {
room = try await database.rooms.get(id)
}
return room
} onSuccess: { room in
RoomForm(dismiss: dismiss, projectID: projectID, room: room)
}
case .index:
return request.view {
ProjectView(projectID: projectID, activeTab: .rooms)
}
return await roomsView(on: request, projectID: projectID)
case .submit(let form):
return await request.view {
await ResultView {
request.logger.debug("New room form submitted.")
// FIX: Just return a room row??
let _ = try await database.rooms.create(form)
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .rooms)
}
return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.create(form)
}
case .update(let id, let form):
return await ResultView {
let _ = try await database.rooms.update(id, form)
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .rooms)
return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.update(id, form)
}
case .updateSensibleHeatRatio(let form):
return await request.view {
await ResultView {
let _ = try await database.projects.update(
form.projectID,
.init(sensibleHeatRatio: form.sensibleHeatRatio)
)
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .rooms)
return await roomsView(on: request, projectID: projectID) {
_ = try await database.projects.update(
form.projectID,
.init(sensibleHeatRatio: form.sensibleHeatRatio)
)
}
}
}
func roomsView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.rooms.fetch(projectID),
try await database.projects.getSensibleHeatRatio(projectID)
)
} onSuccess: { (steps, rooms, shr) in
ProjectView(projectID: projectID, activeTab: .rooms, completedSteps: steps) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
}
}
}
@@ -283,25 +344,54 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) -> AnySendableHTML {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
switch self {
case .index:
return request.view {
ProjectView(projectID: projectID, activeTab: .frictionRate)
}
return await view(on: request, projectID: projectID)
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
return await request.view {
await ResultView {
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRate: frictionRate
)
}
case .form(let type, let dismiss):
// FIX: Forms need to reference existing items.
switch type {
case .equipmentInfo:
return div { "REMOVE ME!" }
// return EquipmentForm(dismiss: dismiss, projectID: projectID)
case .componentPressureLoss:
return ComponentLossForm(dismiss: dismiss, projectID: projectID, componentLoss: nil)
}
}
}
}
extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
@@ -316,37 +406,50 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
case .index:
return EmptyHTML()
case .delete(let id):
return await ResultView {
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id)
} onSuccess: {
EmptyHTML()
}
// return EmptyHTML()
case .submit(let form):
return await ResultView {
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form)
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .frictionRate)
}
case .update(let id, let form):
return await ResultView {
_ = try await database.componentLoss.update(id, form)
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .frictionRate)
}
}
}
}
extension SiteRoute.View.ProjectRoute.FrictionRateRoute.FormType {
var id: String {
switch self {
case .equipmentInfo:
return "equipmentForm"
case .componentPressureLoss:
return "componentLossForm"
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form)
}
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await projectClient.frictionRate(projectID)
)
} onSuccess: { (steps, response) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: response.componentLosses,
equivalentLengths: response.equivalentLengths,
frictionRate: response.frictionRate
)
}
}
}
}
}
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
@@ -365,11 +468,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
}
case .index:
return request.view {
ProjectView(projectID: projectID, activeTab: .equivalentLength)
}
case .form(let dismiss):
return EffectiveLengthForm(projectID: projectID, dismiss: dismiss)
return await self.view(on: request, projectID: projectID)
case .field(let type, let style):
switch type {
@@ -381,10 +480,8 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
}
case .update(let id, let form):
return await ResultView {
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .equivalentLength)
}
case .submit(let step):
@@ -417,19 +514,38 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
)
}
case .three(let stepThree):
return await ResultView {
request.logger.debug("ViewController: Got step three: \(stepThree)")
try stepThree.validate()
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create(
.init(form: stepThree, projectID: projectID))
} onSuccess: {
ProjectView(projectID: projectID, activeTab: .equivalentLength)
.init(form: stepThree, projectID: projectID)
)
}
}
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID)
)
} onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
EffectiveLengthsView(effectiveLengths: equivalentLengths)
.environment(ProjectViewValue.$projectID, projectID)
}
}
}
}
}
extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
@@ -440,78 +556,128 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
return request.view {
ProjectView(projectID: projectID, activeTab: .ductSizing, logger: request.logger)
}
return await view(on: request, projectID: projectID)
case .deleteRectangularSize(let roomID, let rectangularSizeID):
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .roomRectangularForm(let roomID, let form):
return await ResultView {
let room = try await database.rooms.update(
let room = try await database.rooms.updateRectangularSize(
roomID,
.init(
rectangularSizes: [
.init(id: form.id ?? .init(), register: form.register, height: form.height)
]
)
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .trunk(let route):
switch route {
case .delete(let id):
return await ResultView {
try await database.trunkSizes.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.create(
form.toCreate(logger: request.logger)
)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.update(id, form.toUpdate())
}
}
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var project
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await project.calculateDuctSizes(projectID)
)
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(ductSizes: ducts)
}
}
}
}
}
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
showSidebar: Bool = true,
@HTMLBuilder inner: () async throws -> C
) async throws -> AnySendableHTML where C: Sendable {
let inner = try await inner()
if isHtmxRequest {
return inner
extension SiteRoute.View.UserRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
switch self {
case .profile(let route):
return await route.renderView(on: request)
}
}
return MainPage { inner }
}
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
showSidebar: Bool = true,
@HTMLBuilder inner: () async -> C
) async -> AnySendableHTML where C: Sendable {
let inner = await inner()
if isHtmxRequest {
return inner
}
return MainPage { inner }
}
extension SiteRoute.View.UserRoute.Profile {
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
showSidebar: Bool = true,
@HTMLBuilder inner: () -> C
) -> AnySendableHTML where C: Sendable {
let inner = inner()
if isHtmxRequest {
return inner
func renderView(
on request: ViewController.Request
) async -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .index:
return await view(on: request)
case .submit(let form):
return await view(on: request) {
_ = try await database.userProfile.create(form)
}
case .update(let id, let updates):
return await view(on: request) {
_ = try await database.userProfile.update(id, updates)
}
}
}
func view(
on request: ViewController.Request,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
let user = try request.currentUser()
return (
user,
try await database.userProfile.fetch(user.id)
)
} onSuccess: { (user, profile) in
UserView(user: user, profile: profile)
}
}
}
return MainPage { inner }
}

View File

@@ -42,19 +42,27 @@ struct ComponentLossForm: HTML, Sendable {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
div {
label(.for("name")) { "Name" }
Input(id: "name", placeholder: "Name")
.attributes(.type(.text), .required, .autofocus, .value(componentLoss?.name))
}
div {
label(.for("value")) { "Value" }
Input(id: "value", placeholder: "Pressure loss")
.attributes(
.type(.number), .min("0.03"), .max("1.0"), .step("0.01"), .required,
.value(componentLoss?.value)
)
}
LabeledInput(
"Name",
.name("name"),
.type(.text),
.value(componentLoss?.name),
.placeholder("Name"),
.required,
.autofocus
)
LabeledInput(
"Value",
.name("value"),
.type(.number),
.value(componentLoss?.value),
.placeholder("0.2"),
.min("0.03"),
.max("1.0"),
.step("0.01"),
.required
)
SubmitButton()
.attributes(.class("btn-block"))

View File

@@ -3,57 +3,47 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Load component losses when view appears??
struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss]
let projectID: Project.ID
private var total: Double {
componentPressureLosses.reduce(into: 0) { $0 += $1.value }
componentPressureLosses.total
}
private var sortedLosses: [ComponentPressureLoss] {
componentPressureLosses.sorted {
$0.value > $1.value
}
}
var body: some HTML {
div(
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4
"""
)
) {
div(.class("space-y-4")) {
Row {
div(.class("flex space-x-4 items-center")) {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
div(.class("flex text-primary space-x-2 items-baseline")) {
Number(total)
.attributes(.class("text-xl font-bold badge badge-outline badge-primary"))
span(.class("text-sm italic")) { "Total" }
}
}
Tooltip("Add Component Loss") {
PlusButton()
.attributes(
.class("btn-ghost text-2xl"),
.showModal(id: ComponentLossForm.id())
)
}
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
PlusButton()
.attributes(
.class("btn-primary text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
.tooltip("Add component loss")
}
.attributes(.class("px-4"))
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl font-bold")) {
th { "Name" }
th { "Value" }
th {}
th(.class("min-w-[200px]")) {}
}
}
tbody {
for row in componentPressureLosses {
for row in sortedLosses {
TableRow(row: row)
}
}
}
}
ComponentLossForm(dismiss: true, projectID: projectID, componentLoss: nil)

View File

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

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