35 Commits

Author SHA1 Message Date
18a5ef06d3 feat: Rename items in database client for consistency.
All checks were successful
CI / Linux Tests (push) Successful in 5m24s
2026-01-29 15:47:24 -05:00
6723f7a410 feat: Adds some documentation strings in ManualDCore module. 2026-01-29 15:16:26 -05:00
5440024038 feat: Renames EffectiveLength to EquivalentLength
All checks were successful
CI / Linux Tests (push) Successful in 9m34s
2026-01-29 11:25:20 -05:00
f005b43936 feat: Try to build image in ci instead of relying on just.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-01-29 10:52:07 -05:00
f44b35ab3d feat: Try extractions/setup-just
Some checks failed
CI / Linux Tests (push) Failing after 7s
2026-01-29 10:46:00 -05:00
bbf9a8b390 feat: Setup just directly in ci workflow
Some checks failed
CI / Linux Tests (push) Failing after 10s
2026-01-29 10:43:49 -05:00
c52cee212f feat: Adds ci workflow.
Some checks failed
CI / Linux Tests (push) Failing after 5s
2026-01-29 10:36:29 -05:00
2e2c424850 Merge branch 'feat-pdf' 2026-01-29 09:34:41 -05:00
93894e4c25 feat: Adds pdf client tests, updates view controller snapshots that have changed. 2026-01-29 09:33:43 -05:00
bab031f241 feat: Adds pdf support to docker images. 2026-01-28 16:41:12 -05:00
c82f20bb60 WIP: Mostly done with pdf client, need to add tests. 2026-01-28 15:47:56 -05:00
458b3bd644 feat: Adds EnvClient 2026-01-28 11:34:52 -05:00
58023c4dbc feat: Adds view snapshot tests 2026-01-28 10:26:59 -05:00
30241fec60 feat: Updates pdf css to a smaller font size. 2026-01-27 14:16:25 -05:00
273da46db2 feat: Adds file client. 2026-01-27 13:50:42 -05:00
6064b5267a WIP: Initial pdf generation and download, needs improvement and put somewhere different. 2026-01-27 12:53:55 -05:00
69e8acc5d8 WIP: Cleans up ResultView, fixes creating a new trunk size after changes to the database client, pdf button shows html now. 2026-01-27 10:12:51 -05:00
066b3003d0 WIP: Using project detail for duct size calculations, fix trunk sizes querying database for room data, it's now eagerly loaded. 2026-01-27 08:59:36 -05:00
1663c0a514 WIP: Working on a project detail database request to minimize database calls. 2026-01-26 16:43:16 -05:00
e08d896758 feat: Adds better mock support for models to aid in testing / viewing a mock project for the pdf client. 2026-01-26 13:39:27 -05:00
b3c6c27a96 feat: Updates database client migrations to be called as a function. 2026-01-26 11:13:29 -05:00
5fa11ae584 WIP: Style updates for pdf view. 2026-01-18 18:49:20 -05:00
04a7405ca4 WIP: Html view that prints to pdf ok. 2026-01-17 20:40:49 -05:00
0fe80d05c6 WIP: Begins work on pdf client. 2026-01-17 12:59:46 -05:00
3ec1ee2814 feat: Begins creating an auth client and integrates into view controller routes. 2026-01-16 17:04:05 -05:00
761ba29c1e feat: Cleans up / renames some manual-d client routes. 2026-01-16 12:10:33 -05:00
13c4bb33b5 feat: Reorganizes / creates duct sizes container, uses it in views and projectClient. 2026-01-16 11:40:21 -05:00
146baa7815 feat: Moves TrunkSize to be it's own namespace rather than being under DuctSizing, as it's got it's own database model, etc. 2026-01-16 10:48:07 -05:00
b5436c2073 feat: Moves rectangular size to room namespace instead of under duct sizing, since it's stored on the room database model. 2026-01-16 10:26:11 -05:00
59c1c9ec4a feat: Uses shared duct size container for both room and trunk duct sizing containers. 2026-01-16 10:16:31 -05:00
65fc8565b6 feat: More cleanup / renaming for project client. 2026-01-16 09:40:15 -05:00
d14477e97a feat: Cleans up / moves helpers for view controller to project client. 2026-01-16 09:31:35 -05:00
dbec7fb920 WIP: Attempt at breaking out some logic / middleware between database and view layer, to remove some code from the view controller. Not complete, maybe revert. 2026-01-15 23:02:36 -05:00
6b8cb73434 feat: Adds TODO's. 2026-01-15 19:51:26 -05:00
c6a29313aa fix: Fixes setting theme upon signup not working upon submitting the signup form.
All checks were successful
Create and publish a Docker image / build-and-push-image (push) Successful in 6m52s
2026-01-15 19:23:34 -05:00
112 changed files with 8877 additions and 1703 deletions

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

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

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,60 @@ 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 +175,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 +189,15 @@ let package = Package(
.product(name: "Vapor", package: "vapor"),
]
),
.testTarget(
name: "ViewControllerTests",
dependencies: [
.target(name: "ViewController"),
.target(name: "HTMLSnapshotTesting"),
],
resources: [
.copy("__Snapshots__")
]
),
]
)

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

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

View File

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

View File

@@ -4224,9 +4224,6 @@
.top-2 {
top: calc(var(--spacing) * 2);
}
.top-\[100vh\] {
top: 100vh;
}
.right-2 {
right: calc(var(--spacing) * 2);
}
@@ -4807,18 +4804,12 @@
}
}
}
.col-span-1 {
grid-column: span 1 / span 1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-3 {
grid-column: span 3 / span 3;
}
.col-span-5 {
grid-column: span 5 / span 5;
}
.timeline-end {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -5288,12 +5279,6 @@
}
}
}
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-4 {
margin-inline: calc(var(--spacing) * 4);
}
.mx-auto {
margin-inline: auto;
}
@@ -5383,15 +5368,9 @@
}
}
}
.my-1 {
margin-block: calc(var(--spacing) * 1);
}
.my-1\.5 {
margin-block: calc(var(--spacing) * 1.5);
}
.my-4 {
margin-block: calc(var(--spacing) * 4);
}
.my-6 {
margin-block: calc(var(--spacing) * 6);
}
@@ -5661,9 +5640,6 @@
.mt-6 {
margin-top: calc(var(--spacing) * 6);
}
.mt-auto {
margin-top: auto;
}
.breadcrumbs {
@layer daisyui.l1.l2.l3 {
max-width: 100%;
@@ -5740,9 +5716,6 @@
font-weight: 600;
}
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
@@ -6473,12 +6446,6 @@
.h-full {
height: 100%;
}
.h-screen {
height: 100vh;
}
.min-h-full {
min-height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
@@ -6610,12 +6577,6 @@
width: calc(var(--size-selector, 0.25rem) * 4);
}
}
.w-\[300px\] {
width: 300px;
}
.w-\[310px\] {
width: 310px;
}
.w-\[330px\] {
width: 330px;
}
@@ -6625,18 +6586,12 @@
.w-full {
width: 100%;
}
.min-w-\[100px\] {
min-width: 100px;
}
.min-w-\[200px\] {
min-width: 200px;
}
.min-w-\[220px\] {
min-width: 220px;
}
.min-w-\[300px\] {
min-width: 300px;
}
.min-w-full {
min-width: 100%;
}
@@ -6839,15 +6794,9 @@
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.flex-col {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.flex-wrap {
flex-wrap: wrap;
}
@@ -6872,9 +6821,6 @@
.justify-start {
justify-content: flex-start;
}
.justify-items-end {
justify-items: end;
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
@@ -6884,9 +6830,6 @@
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-1 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -6936,9 +6879,6 @@
margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse)));
}
}
.gap-y-2 {
row-gap: calc(var(--spacing) * 2);
}
.gap-y-4 {
row-gap: calc(var(--spacing) * 4);
}
@@ -7230,10 +7170,6 @@
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-b-1 {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@@ -7507,9 +7443,6 @@
}
}
}
.bg-base-100 {
background-color: var(--color-base-100);
}
.bg-base-300 {
background-color: var(--color-base-300);
}
@@ -7939,9 +7872,6 @@
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
@@ -7968,9 +7898,6 @@
.pt-2 {
padding-top: calc(var(--spacing) * 2);
}
.pt-6 {
padding-top: calc(var(--spacing) * 6);
}
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
}
@@ -8603,9 +8530,6 @@
.text-info {
color: var(--color-info);
}
.text-primary {
color: var(--color-primary);
}
.text-slate-900 {
color: var(--color-slate-900);
}

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

View File

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

View File

@@ -33,6 +33,8 @@ extension SiteRoute.Api.ProjectRoute {
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()
@@ -102,14 +104,14 @@ extension SiteRoute.Api.ComponentLossRoute {
switch self {
case .create(let request):
return try await database.componentLoss.create(request)
return try await database.componentLosses.create(request)
case .delete(let id):
try await database.componentLoss.delete(id)
try await database.componentLosses.delete(id)
return nil
case .fetch(let projectID):
return try await database.componentLoss.fetch(projectID)
return try await database.componentLosses.fetch(projectID)
case .get(let id):
guard let room = try await database.componentLoss.get(id) else {
guard let room = try await database.componentLosses.get(id) else {
logger.error("Component loss not found for id: \(id)")
throw ApiError("Component loss not found.")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,11 +16,108 @@ public struct DatabaseClient: Sendable {
public var projects: Projects
public var rooms: Rooms
public var equipment: Equipment
public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient
public var componentLosses: ComponentLosses
public var equivalentLengths: EquivalentLengths
public var users: Users
public var userProfile: UserProfile
public var userProfiles: UserProfiles
public var trunkSizes: TrunkSizes
@DependencyClient
public struct ComponentLosses: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
@DependencyClient
public struct EquivalentLengths: Sendable {
public var create: @Sendable (EquivalentLength.Create) async throws -> EquivalentLength
public var delete: @Sendable (EquivalentLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EquivalentLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EquivalentLength.MaxContainer
public var get: @Sendable (EquivalentLength.ID) async throws -> EquivalentLength?
public var update:
@Sendable (EquivalentLength.ID, EquivalentLength.Update) async throws -> EquivalentLength
}
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
@DependencyClient
public struct Migrations: Sendable {
public var all: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.all()
}
}
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var detail: @Sendable (Project.ID) async throws -> Project.Detail?
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
}
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (TrunkSize.Create) async throws -> TrunkSize
public var delete: @Sendable (TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [TrunkSize]
public var get: @Sendable (TrunkSize.ID) async throws -> TrunkSize?
public var update:
@Sendable (TrunkSize.ID, TrunkSize.Update) async throws ->
TrunkSize
}
@DependencyClient
public struct UserProfiles: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient: TestDependencyKey {
@@ -29,10 +126,10 @@ extension DatabaseClient: TestDependencyKey {
projects: .testValue,
rooms: .testValue,
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
componentLosses: .testValue,
equivalentLengths: .testValue,
users: .testValue,
userProfile: .testValue,
userProfiles: .testValue,
trunkSizes: .testValue
)
@@ -42,29 +139,20 @@ extension DatabaseClient: TestDependencyKey {
projects: .live(database: database),
rooms: .live(database: database),
equipment: .live(database: database),
componentLoss: .live(database: database),
effectiveLength: .live(database: database),
componentLosses: .live(database: database),
equivalentLengths: .live(database: database),
users: .live(database: database),
userProfile: .live(database: database),
userProfiles: .live(database: database),
trunkSizes: .live(database: database)
)
}
}
extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
}
}
extension DatabaseClient.Migrations: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Migrations: DependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
run: {
all: {
[
Project.Migrate(),
User.Migrate(),
@@ -73,8 +161,8 @@ extension DatabaseClient.Migrations: DependencyKey {
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
DuctSizing.TrunkSize.Migrate(),
EquivalentLength.Migrate(),
TrunkSize.Migrate(),
]
}
)

View File

@@ -4,25 +4,11 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct ComponentLoss: Sendable {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
}
extension DatabaseClient.ComponentLoss: TestDependencyKey {
extension DatabaseClient.ComponentLosses: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.ComponentLoss {
extension DatabaseClient.ComponentLosses {
public static func live(database: any Database) -> Self {
.init(
create: { request in

View File

@@ -4,20 +4,7 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct EffectiveLengthClient: Sendable {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
public var update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
}
}
extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
@@ -74,7 +61,7 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
}
}
extension EffectiveLength.Create {
extension EquivalentLength.Create {
func toModel() throws -> EffectiveLengthModel {
try validate()
@@ -94,7 +81,7 @@ extension EffectiveLength.Create {
}
}
extension EffectiveLength {
extension EquivalentLength {
struct Migrate: AsyncMigration {
let name = "CreateEffectiveLength"
@@ -173,20 +160,20 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
$project.id = projectID
}
func toDTO() throws -> EffectiveLength {
func toDTO() throws -> EquivalentLength {
try .init(
id: requireID(),
projectID: $project.id,
name: name,
type: .init(rawValue: type)!,
straightLengths: straightLengths,
groups: JSONDecoder().decode([EffectiveLength.Group].self, from: groups),
groups: JSONDecoder().decode([EquivalentLength.FittingGroup].self, from: groups),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EffectiveLength.Update) throws {
func applyUpdates(_ updates: EquivalentLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}

View File

@@ -4,23 +4,9 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Equipment: Sendable {
public var create: @Sendable (EquipmentInfo.Create) async throws -> EquipmentInfo
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
}
extension DatabaseClient.Equipment: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Equipment {
public static func live(database: any Database) -> Self {
.init(
create: { request in

View File

@@ -4,19 +4,6 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Projects: Sendable {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
public var get: @Sendable (Project.ID) async throws -> Project?
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
}
extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self()
@@ -33,6 +20,44 @@ extension DatabaseClient.Projects: TestDependencyKey {
}
try await model.delete(on: database)
},
detail: { id in
guard
let model = try await ProjectModel.query(on: database)
.with(\.$componentLosses)
.with(\.$equipment)
.with(\.$equivalentLengths)
.with(\.$rooms)
.with(
\.$trunks,
{ trunk in
trunk.with(
\.$rooms,
{
$0.with(\.$room)
}
)
}
)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
// TODO: Different error ??
guard let equipmentInfo = model.equipment else { return nil }
let trunks = try model.trunks.toDTO()
return try .init(
project: model.toDTO(),
componentLosses: model.componentLosses.map { try $0.toDTO() },
equipmentInfo: equipmentInfo.toDTO(),
equivalentLengths: model.equivalentLengths.map { try $0.toDTO() },
rooms: model.rooms.map { try $0.toDTO() },
trunks: trunks
)
},
get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
@@ -73,10 +98,16 @@ extension DatabaseClient.Projects: TestDependencyKey {
)
},
getSensibleHeatRatio: { id in
guard let model = try await ProjectModel.find(id, on: database) else {
guard
let shr = try await ProjectModel.query(on: database)
.field(\.$id)
.field(\.$sensibleHeatRatio)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
return model.sensibleHeatRatio
return shr.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
@@ -242,6 +273,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

@@ -4,21 +4,6 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct.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, DuctSizing.RectangularDuct) async throws -> Room
}
}
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
@@ -204,7 +189,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?
@@ -224,7 +209,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

@@ -4,19 +4,6 @@ import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
public var update:
@Sendable (DuctSizing.TrunkSize.ID, DuctSizing.TrunkSize.Update) async throws ->
DuctSizing.TrunkSize
}
}
extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self()
@@ -26,7 +13,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
try request.validate()
let trunk = request.toModel()
var roomProxies = [DuctSizing.TrunkSize.RoomProxy]()
var roomProxies = [TrunkSize.RoomProxy]()
try await trunk.save(on: database)
@@ -41,7 +28,9 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
type: request.type
)
try await model.save(on: database)
try await roomProxies.append(model.toDTO(on: database))
roomProxies.append(
.init(room: try room.toDTO(), registers: registers)
)
}
return try .init(
@@ -60,23 +49,30 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$project.$id == projectID)
.all()
.toDTO(on: database)
.toDTO()
},
get: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
return nil
}
return try await model.toDTO(on: database)
return try model.toDTO()
},
update: { id, updates in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms)
.with(\.$rooms, { $0.with(\.$room) })
.filter(\.$id == id)
.first()
else {
@@ -84,13 +80,13 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
}
try updates.validate()
try await model.applyUpdates(updates, on: database)
return try await model.toDTO(on: database)
return try model.toDTO()
}
)
}
}
extension DuctSizing.TrunkSize.Create {
extension TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
@@ -113,7 +109,7 @@ extension DuctSizing.TrunkSize.Create {
}
}
extension DuctSizing.TrunkSize.Update {
extension TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
@@ -128,7 +124,7 @@ extension DuctSizing.TrunkSize.Update {
}
}
extension DuctSizing.TrunkSize {
extension TrunkSize {
struct Migrate: AsyncMigration {
let name = "CreateTrunkSize"
@@ -192,7 +188,7 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int],
type: DuctSizing.TrunkSize.TrunkType
type: TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
@@ -201,10 +197,10 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
self.type = type.rawValue
}
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy {
guard let room = try await RoomModel.find($room.id, on: database) else {
throw NotFoundError()
}
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
@@ -240,7 +236,7 @@ final class TrunkModel: Model, @unchecked Sendable {
init(
id: UUID? = nil,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil
) {
@@ -251,18 +247,22 @@ final class TrunkModel: Model, @unchecked Sendable {
self.name = name
}
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize {
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in
for room in self.rooms {
group.addTask {
try await room.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
$0.append($1)
}
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(
@@ -277,7 +277,7 @@ final class TrunkModel: Model, @unchecked Sendable {
}
func applyUpdates(
_ updates: DuctSizing.TrunkSize.Update,
_ updates: TrunkSize.Update,
on database: any Database
) async throws {
if let type = updates.type, type.rawValue != self.type {
@@ -340,17 +340,17 @@ final class TrunkModel: Model, @unchecked Sendable {
extension Array where Element == TrunkModel {
func toDTO(on database: any Database) async throws -> [DuctSizing.TrunkSize] {
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
for model in self {
group.addTask {
try await model.toDTO(on: database)
}
}
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 await group.reduce(into: [DuctSizing.TrunkSize]()) {
$0.append($1)
}
return try reduce(into: [TrunkSize]()) {
$0.append(try $1.toDTO())
}
}
// }
}

View File

@@ -4,18 +4,7 @@ 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 {
extension DatabaseClient.UserProfiles: TestDependencyKey {
public static let testValue = Self()

View File

@@ -4,19 +4,6 @@ import Fluent
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self()

View File

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

View File

@@ -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,7 +14,7 @@ extension Room {
}
}
extension DuctSizing.TrunkSize.RoomProxy {
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
@@ -35,7 +35,7 @@ extension DuctSizing.TrunkSize.RoomProxy {
}
}
extension DuctSizing.TrunkSize {
extension TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
@@ -46,10 +46,6 @@ extension DuctSizing.TrunkSize {
}
}
extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
}
extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength }

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)
@@ -28,15 +28,15 @@ extension ManualDClient: DependencyKey {
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)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
}

View File

@@ -1,274 +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],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
try await (
calculateSizes(
rooms: rooms, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR
),
calculateSizes(
rooms: rooms, trunks: trunks, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR)
)
}
func calculateSizes(
rooms: [Room],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
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(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: 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
)
)
}
}
return retval
}
func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.TrunkContainer] {
var retval = [DuctSizing.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(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)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: trunk.height,
width: width
)
)
)
}
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,5 +1,10 @@
import Dependencies
import Foundation
/// Represents component pressure losses used in the friction rate worksheet.
///
/// These are items such as filter, evaporator-coils, balance-dampers, etc. that
/// need to be overcome by the system fan.
public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
@@ -43,7 +48,7 @@ extension ComponentPressureLoss {
self.value = value
}
// Return's commonly used default component pressure losses.
/// Commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] {
[
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
@@ -74,22 +79,63 @@ extension Array where Element == ComponentPressureLoss {
}
}
public typealias ComponentPressureLosses = [String: Double]
#if DEBUG
extension ComponentPressureLosses {
public static var mock: Self {
[
"evaporator-coil": 0.2,
"filter": 0.1,
"supply-outlet": 0.03,
"return-grille": 0.03,
"balancing-damper": 0.03,
]
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,248 +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 SizeContainer: Codable, Equatable, Sendable {
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(
designCFM: DuctSizing.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
// TODO: Uses SizeContainer
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 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(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
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.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}
extension DuctSizing {
// 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<DuctSizing.TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
// TODO: Add an optional label that the user can set.
// 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: DuctSizing.TrunkSize.TrunkType,
rooms: [DuctSizing.TrunkSize.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 DuctSizing.TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: DuctSizing.TrunkSize.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: DuctSizing.TrunkSize.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
}
}
// TODO: Make registers non-optional
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
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]
}
}

View File

@@ -1,177 +0,0 @@
import Dependencies
import Foundation
// TODO: Not sure how to model effective length groups in the database.
// thinking perhaps just have a 'data' field that encoded / decodes
// to swift types??
public struct EffectiveLength: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group],
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
extension EffectiveLength {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let type: EffectiveLengthType
public let straightLengths: [Int]
public let groups: [Group]
public init(
projectID: Project.ID,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int],
groups: [EffectiveLength.Group]
) {
self.projectID = projectID
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let type: EffectiveLengthType?
public let straightLengths: [Int]?
public let groups: [Group]?
public init(
name: String? = nil,
type: EffectiveLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EffectiveLength.Group]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return`
case supply
}
public struct Group: Codable, Equatable, Sendable {
public let group: Int
public let letter: String
public let value: Double
public let quantity: Int
public init(
group: Int,
letter: String,
value: Double,
quantity: Int = 1
) {
self.group = group
self.letter = letter
self.value = value
self.quantity = quantity
}
}
public struct MaxContainer: Codable, Equatable, Sendable {
public let supply: EffectiveLength?
public let `return`: EffectiveLength?
public var total: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
self.supply = supply
self.return = `return`
}
}
}
extension EffectiveLength {
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EffectiveLength.Group {
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
}
#if DEBUG
extension EffectiveLength {
public static let mocks: [Self] = [
.init(
id: UUID(0),
projectID: UUID(0),
name: "Test Supply - 1",
type: .supply,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
.init(
id: UUID(1),
projectID: UUID(0),
name: "Test Return - 1",
type: .return,
straightLengths: [10, 20, 25],
groups: [
.init(group: 1, letter: "a", value: 20),
.init(group: 2, letter: "b", value: 15, quantity: 2),
.init(group: 3, letter: "c", value: 10, quantity: 1),
],
createdAt: Date(),
updatedAt: Date()
),
]
}
#endif

View File

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

View File

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

View File

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

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

View File

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

@@ -1,16 +1,37 @@
import Dependencies
import Foundation
/// Represents a room in a project.
///
/// This contains data such as the heating and cooling load for the
/// room, the number of registers in the room, and any rectangular
/// duct size calculations stored for the room.
public struct Room: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the room.
public let id: UUID
/// The project this room is associated with.
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double
/// An optional sensible cooling load for the room.
///
/// **NOTE:** This is generally not set, but calculated from the project wide
/// sensible heat ratio.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
/// The rectangular duct size calculations for a room.
///
/// **NOTE:** These are optionally set after the round sizes have been calculate
/// for a room.
public let rectangularSizes: [RectangularSize]?
/// When the room was created in the database.
public let createdAt: Date
/// When the room was updated in the database.
public let updatedAt: Date
public init(
@@ -21,7 +42,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
) {
@@ -39,13 +60,19 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
}
extension Room {
/// Represents the data required to create a new room for a project.
public struct Create: Codable, Equatable, Sendable {
/// The project this room is associated with.
public let projectID: Project.ID
/// A unique name for the room in the project.
public let name: String
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int
public init(
@@ -65,13 +92,46 @@ extension Room {
}
}
/// Represents a rectangular size calculation that is stored in the
/// database for a given room.
///
/// These are done after the round duct sizes have been calculated and
/// can be used to calculate the equivalent rectangular size for a given run.
public struct RectangularSize: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the rectangular size.
public let id: UUID
/// The register the rectangular size is associated with.
public let register: Int?
/// The height of the rectangular size, the width gets calculated.
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
/// Represents field that can be updated on a room after it's been created in the database.
///
/// Only fields that are supplied get updated.
public struct Update: Codable, Equatable, Sendable {
/// A unique name for the room in the project.
public let name: String?
/// The heating load required for the room (from Manual-J).
public let heatingLoad: Double?
/// The total cooling load required for the room (from Manual-J).
public let coolingTotal: Double?
/// An optional sensible cooling load for the room.
public let coolingSensible: Double?
/// The number of registers for the room.
public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]?
/// The rectangular duct size calculations for a room.
public let rectangularSizes: [RectangularSize]?
public init(
name: String? = nil,
@@ -89,7 +149,7 @@ extension Room {
}
public init(
rectangularSizes: [DuctSizing.RectangularDuct]
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.heatingLoad = nil
@@ -103,14 +163,20 @@ extension Room {
extension Array where Element == Room {
/// The sum of heating loads for an array of rooms.
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
/// The sum of total cooling loads for an array of rooms.
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
/// The sum of sensible cooling loads for an array of rooms.
///
/// - Parameters:
/// - shr: The project wide sensible heat ratio.
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
@@ -122,38 +188,86 @@ extension Array where Element == Room {
#if DEBUG
extension Room {
public static let mocks = [
Room(
id: UUID(0),
projectID: UUID(0),
name: "Kitchen",
heatingLoad: 12345,
coolingTotal: 1234,
registerCount: 2,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(1),
projectID: UUID(1),
name: "Bedroom - 1",
heatingLoad: 12345,
coolingTotal: 1456,
registerCount: 1,
createdAt: Date(),
updatedAt: Date()
),
Room(
id: UUID(2),
projectID: UUID(2),
name: "Family Room",
heatingLoad: 12345,
coolingTotal: 1673,
registerCount: 3,
createdAt: Date(),
updatedAt: Date()
),
]
public static func mock(projectID: Project.ID) -> [Self] {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return [
.init(
id: uuid(),
projectID: projectID,
name: "Bed-1",
heatingLoad: 3913,
coolingTotal: 2472,
coolingSensible: nil,
registerCount: 1,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Entry",
heatingLoad: 8284,
coolingTotal: 2916,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Family Room",
heatingLoad: 9785,
coolingTotal: 7446,
coolingSensible: nil,
registerCount: 3,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Kitchen",
heatingLoad: 4518,
coolingTotal: 5096,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Living Room",
heatingLoad: 7553,
coolingTotal: 6829,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
.init(
id: uuid(),
projectID: projectID,
name: "Master",
heatingLoad: 8202,
coolingTotal: 2076,
coolingSensible: nil,
registerCount: 2,
rectangularSizes: nil,
createdAt: now,
updatedAt: now
),
]
}
}
#endif

View File

@@ -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
@@ -221,10 +226,10 @@ extension SiteRoute.Api {
extension SiteRoute.Api {
public enum EffectiveLengthRoute: Equatable, Sendable {
case create(EffectiveLength.Create)
case delete(id: EffectiveLength.ID)
case create(EquivalentLength.Create)
case delete(id: EquivalentLength.ID)
case fetch(projectID: Project.ID)
case get(id: EffectiveLength.ID)
case get(id: EquivalentLength.ID)
static let rootPath = "effectiveLength"
@@ -235,12 +240,12 @@ extension SiteRoute.Api {
"create"
}
Method.post
Body(.json(EffectiveLength.Create.self))
Body(.json(EquivalentLength.Create.self))
}
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.delete
}
@@ -256,7 +261,7 @@ extension SiteRoute.Api {
Route(.case(Self.get(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.get
}

View File

@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute)
static let router = OneOf {
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) {
FrictionRateRoute.router
}
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) {
RoomRoute.router
}
@@ -389,11 +394,11 @@ extension SiteRoute.View.ProjectRoute {
}
public enum EquivalentLengthRoute: Equatable, Sendable {
case delete(id: EffectiveLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
case delete(id: EquivalentLength.ID)
case field(FieldType, style: EquivalentLength.EffectiveLengthType? = nil)
case index
case submit(FormStep)
case update(EffectiveLength.ID, StepThree)
case update(EquivalentLength.ID, StepThree)
static let rootPath = "effective-lengths"
@@ -401,7 +406,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.delete
}
@@ -419,7 +424,7 @@ extension SiteRoute.View.ProjectRoute {
Field("type") { FieldType.parser() }
Optionally {
Field("style", default: nil) {
EffectiveLength.EffectiveLengthType.parser()
EquivalentLength.EffectiveLengthType.parser()
}
}
}
@@ -432,16 +437,16 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) {
Path {
rootPath
EffectiveLength.ID.parser()
EquivalentLength.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -485,10 +490,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
}
.map(.memberwise(StepOne.init))
}
@@ -500,10 +505,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -520,10 +525,10 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
@@ -562,22 +567,22 @@ extension SiteRoute.View.ProjectRoute {
}
public struct StepOne: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
}
public struct StepTwo: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int]
public init(
id: EffectiveLength.ID? = nil,
id: EquivalentLength.ID? = nil,
name: String,
type: EffectiveLength.EffectiveLengthType,
type: EquivalentLength.EffectiveLengthType,
straightLengths: [Int]
) {
self.id = id
@@ -588,9 +593,9 @@ extension SiteRoute.View.ProjectRoute {
}
public struct StepThree: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let id: EquivalentLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let type: EquivalentLength.EffectiveLengthType
public let straightLengths: [Int]
public let groupGroups: [Int]
public let groupLetters: [String]
@@ -627,7 +632,7 @@ extension SiteRoute.View.ProjectRoute {
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("rectangularSize") { Room.RectangularSize.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
@@ -642,7 +647,7 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
Field("id") { Room.RectangularSize.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
@@ -658,19 +663,19 @@ extension SiteRoute.View.ProjectRoute {
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
public let rectangularSizeID: Room.RectangularSize.ID
public let register: Int
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
public init(rectangularSizeID: Room.RectangularSize.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID)
case delete(TrunkSize.ID)
case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
case update(TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
@@ -678,7 +683,7 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.delete)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
TrunkSize.ID.parser()
}
Method.delete
}
@@ -690,7 +695,7 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
@@ -708,13 +713,13 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.update)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Field("type") { TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
@@ -732,17 +737,43 @@ extension SiteRoute.View.ProjectRoute {
}
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: DuctSizing.TrunkSize.TrunkType
public let type: TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
public init(
projectID: Project.ID,
type: TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil,
rooms: [String]
) {
self.projectID = projectID
self.type = type
self.height = height
self.name = name
self.rooms = rooms
}
}
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
/// Represents supported color themes for the website.
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua
case cupcake
@@ -13,6 +14,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case retro
case synthwave
/// Represents dark color themes.
public static let darkThemes = [
Self.aqua,
Self.cyberpunk,
@@ -22,6 +24,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
Self.synthwave,
]
/// Represents light color themes.
public static let lightThemes = [
Self.cupcake,
Self.light,

View File

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

View File

@@ -1,12 +1,17 @@
import Dependencies
import Foundation
// FIX: Remove username.
/// Represents a user of the site.
///
public struct User: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the user.
public let id: UUID
/// The user's email address.
public let email: String
/// When the user was created in the database.
public let createdAt: Date
/// When the user was updated in the database.
public let updatedAt: Date
public init(
@@ -23,10 +28,14 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
}
extension User {
/// Represents the data required to create a new user.
public struct Create: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// The password confirmation, must match the password.
public let confirmPassword: String
public init(
@@ -40,9 +49,13 @@ extension User {
}
}
/// Represents data required to login a user.
public struct Login: Codable, Equatable, Sendable {
/// The user's email address.
public let email: String
/// The password for the user.
public let password: String
/// An optional page / route to navigate to after logging in the user.
public let next: String?
public init(email: String, password: String, next: String? = nil) {
@@ -52,10 +65,13 @@ extension User {
}
}
/// Represents a user session token, for a logged in user.
public struct Token: Codable, Equatable, Identifiable, Sendable {
/// The unique id of the token.
public let id: UUID
/// The user id the token is for.
public let userID: User.ID
/// The token value.
public let value: String
public init(id: UUID, userID: User.ID, value: String) {
@@ -65,3 +81,15 @@ extension User {
}
}
}
#if DEBUG
extension User {
public static var mock: Self {
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var now
return .init(id: uuid(), email: "testy@example.com", createdAt: now, updatedAt: now)
}
}
#endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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: EquivalentLength.MaxContainer
public let frictionRate: FrictionRate?
public init(
componentLosses: [ComponentPressureLoss],
equivalentLengths: EquivalentLength.MaxContainer,
frictionRate: FrictionRate? = nil
) {
self.componentLosses = componentLosses
self.equivalentLengths = equivalentLengths
self.frictionRate = frictionRate
}
}
}

View File

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

View File

@@ -0,0 +1,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: EquivalentLength, return: EquivalentLength)
let designFrictionRate: Double
let equipmentInfo: EquipmentInfo
let telMaxContainer: EquivalentLength.MaxContainer
func ensureMaxContainer() throws -> EnsuredTEL {
guard let maxSupplyLength = telMaxContainer.supply else {
throw ProjectClientError("Max supply TEL not found")
}
guard let maxReturnLength = telMaxContainer.return else {
throw ProjectClientError("Max supply TEL not found")
}
return (maxSupplyLength, maxReturnLength)
}
}
func designFrictionRate(
componentLosses: [ComponentPressureLoss],
equipmentInfo: EquipmentInfo,
equivalentLengths: EquivalentLength.MaxContainer
) -> DesignFrictionRateResponse? {
guard let tel = equivalentLengths.totalEquivalentLength,
componentLosses.count > 0
else { return nil }
let availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total
return .init(
designFrictionRate: (availableStaticPressure * 100) / tel,
equipmentInfo: equipmentInfo,
telMaxContainer: equivalentLengths
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> DesignFrictionRateResponse? {
guard let equipmentInfo = try await equipment.fetch(projectID) else {
return nil
}
return try await designFrictionRate(
componentLosses: componentLosses.fetch(projectID),
equipmentInfo: equipmentInfo,
equivalentLengths: equivalentLengths.fetchMax(projectID)
)
}
}

View File

@@ -0,0 +1,223 @@
import Logging
import ManualDClient
import ManualDCore
struct DuctSizeSharedRequest {
let equipmentInfo: EquipmentInfo
let maxSupplyLength: EquivalentLength
let maxReturnLenght: EquivalentLength
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.totalEquivalentLength 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.componentLosses.fetch(projectID)
let lengths = try await database.equivalentLengths.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.totalEquivalentLength else {
return .init(componentLosses: componentLosses, equivalentLengths: lengths)
}
return try await .init(
componentLosses: componentLosses,
equivalentLengths: lengths,
frictionRate: frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
)
)
)
}
}

View File

@@ -0,0 +1,14 @@
import ManualDCore
extension Project.Detail {
var maxContainer: EquivalentLength.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.componentLosses.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

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

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

View File

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

@@ -1,78 +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 -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
@Dependency(\.manualD) var manualD
return try await manualD.calculate(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.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.total
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

@@ -12,8 +12,8 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
}
}
var groups: [EffectiveLength.Group] {
var groups = [EffectiveLength.Group]()
var groups: [EquivalentLength.FittingGroup] {
var groups = [EquivalentLength.FittingGroup]()
for (n, group) in groupGroups.enumerated() {
groups.append(
.init(
@@ -29,7 +29,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
}
}
extension EffectiveLength.Create {
extension EquivalentLength.Create {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
@@ -45,7 +45,7 @@ extension EffectiveLength.Create {
}
}
extension EffectiveLength.Update {
extension EquivalentLength.Update {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID

View File

@@ -1,38 +0,0 @@
import Logging
import ManualDClient
import ManualDCore
extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
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,
trunks: trunks,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,
designFrictionRate: designFrictionRate,
projectSHR: projectSHR ?? 1.0,
logger: logger
)
// logger?.debug("Rooms: \(ductRooms)")
return ductRooms
}
}

View File

@@ -4,7 +4,7 @@ import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create {
func toCreate(logger: Logger? = nil) throws -> TrunkSize.Create {
try .init(
projectID: projectID,
type: type,
@@ -14,7 +14,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
)
}
func toUpdate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Update {
func toUpdate(logger: Logger? = nil) throws -> TrunkSize.Update {
try .init(
type: type,
rooms: makeRooms(logger: logger),

View File

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

View File

@@ -3,6 +3,8 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import PdfClient
import ProjectClient
import Styleguide
extension ViewController.Request {
@@ -10,20 +12,28 @@ extension ViewController.Request {
func render() async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
@Dependency(\.pdfClient) var pdfClient
switch route {
case .test:
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
return await view {
await ResultView {
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (_, result) in
TestPage(trunks: result.trunks, rooms: result.rooms)
}
}
// 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):
@@ -60,15 +70,18 @@ extension ViewController.Request {
case .submitProfile(let profile):
return await view {
await ResultView {
_ = try await database.userProfile.create(profile)
_ = try await database.userProfiles.create(profile)
let userID = profile.userID
// let user = try currentUser()
return (
userID,
try await database.projects.fetch(userID, .init(page: 1, per: 25))
try await database.projects.fetch(userID, .init(page: 1, per: 25)),
profile.theme
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
} onSuccess: { (userID, projects, theme) in
MainPage(displayFooter: true, theme: theme) {
ProjectsTable(userID: userID, projects: projects)
}
}
}
}
@@ -95,7 +108,7 @@ extension ViewController.Request {
get async {
@Dependency(\.database) var database
guard let user = try? currentUser() else { return nil }
return try? await database.userProfile.fetch(user.id)?.theme
return try? await database.userProfiles.fetch(user.id)?.theme
}
}
@@ -113,6 +126,7 @@ extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -121,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
@@ -143,19 +157,14 @@ extension SiteRoute.View.ProjectRoute {
return await request.view {
await ResultView {
let user = try request.currentUser()
let project = try await database.projects.create(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id)
let rooms = try await database.rooms.fetch(project.id)
let shr = try await database.projects.getSensibleHeatRatio(project.id)
let completedSteps = try await database.projects.getCompletedSteps(project.id)
return (project.id, rooms, shr, completedSteps)
} onSuccess: { (projectID, rooms, shr, completedSteps) in
return try await projectClient.createProject(user.id, form)
} onSuccess: { response in
ProjectView(
projectID: projectID,
projectID: response.projectID,
activeTab: .rooms,
completedSteps: completedSteps
completedSteps: response.completedSteps
) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
RoomsView(rooms: response.rooms, sensibleHeatRatio: response.sensibleHeatRatio)
}
}
}
@@ -184,6 +193,12 @@ extension SiteRoute.View.ProjectRoute {
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route):
return await route.renderView(on: request, projectID: projectID)
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
// return try! await projectClient.toHTML(projectID)
// This get's handled elsewhere because it returns a response, not a view.
fatalError()
case .rooms(let route):
return await route.renderView(on: request, projectID: projectID)
}
@@ -351,8 +366,8 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
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)
let componentLosses = try await database.componentLosses.fetch(projectID)
let lengths = try await database.equivalentLengths.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
@@ -369,7 +384,7 @@ extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
frictionRate: frictionRate
)
}
@@ -392,16 +407,16 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
return EmptyHTML()
case .delete(let id):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id)
_ = try await database.componentLosses.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form)
_ = try await database.componentLosses.create(form)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form)
_ = try await database.componentLosses.update(id, form)
}
}
}
@@ -413,32 +428,21 @@ extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
return await request.view {
await ResultView {
try await catching()
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
try await projectClient.frictionRate(projectID)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
} onSuccess: { (steps, response) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
componentLosses: response.componentLosses,
equivalentLengths: response.equivalentLengths,
frictionRate: response.frictionRate
)
}
@@ -460,7 +464,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .delete(let id):
return await ResultView {
try await database.effectiveLength.delete(id)
try await database.equivalentLengths.delete(id)
}
case .index:
@@ -477,16 +481,16 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
_ = try await database.equivalentLengths.update(id, .init(form: form, projectID: projectID))
}
case .submit(let step):
switch step {
case .one(let stepOne):
return await ResultView {
var effectiveLength: EffectiveLength? = nil
var effectiveLength: EquivalentLength? = nil
if let id = stepOne.id {
effectiveLength = try await database.effectiveLength.get(id)
effectiveLength = try await database.equivalentLengths.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
@@ -499,9 +503,9 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
case .two(let stepTwo):
return await ResultView {
request.logger.debug("ViewController: Got step two...")
var effectiveLength: EffectiveLength? = nil
var effectiveLength: EquivalentLength? = nil
if let id = stepTwo.id {
effectiveLength = try await database.effectiveLength.get(id)
effectiveLength = try await database.equivalentLengths.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
@@ -511,7 +515,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
}
case .three(let stepThree):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create(
_ = try await database.equivalentLengths.create(
.init(form: stepThree, projectID: projectID)
)
}
@@ -532,7 +536,7 @@ extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID)
try await database.equivalentLengths.fetch(projectID)
)
} onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
@@ -552,6 +556,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.projectClient) var projectClient
switch self {
case .index:
@@ -560,8 +565,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { room in
@@ -574,8 +578,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
roomID,
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
return try await projectClient.calculateRoomDuctSizes(projectID)
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { room in
@@ -609,17 +612,18 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.projectClient) var project
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
try await project.calculateDuctSizes(projectID)
)
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
DuctSizingView(ductSizes: ducts)
}
}
}
@@ -648,11 +652,11 @@ extension SiteRoute.View.UserRoute.Profile {
return await view(on: request)
case .submit(let form):
return await view(on: request) {
_ = try await database.userProfile.create(form)
_ = try await database.userProfiles.create(form)
}
case .update(let id, let updates):
return await view(on: request) {
_ = try await database.userProfile.update(id, updates)
_ = try await database.userProfiles.update(id, updates)
}
}
}
@@ -669,7 +673,7 @@ extension SiteRoute.View.UserRoute.Profile {
let user = try request.currentUser()
return (
user,
try await database.userProfile.fetch(user.id)
try await database.userProfiles.fetch(user.id)
)
} onSuccess: { (user, profile) in
UserView(user: user, profile: profile)

View File

@@ -7,8 +7,7 @@ struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let trunks: [DuctSizing.TrunkContainer]
let ductSizes: DuctSizes
var body: some HTML {
div(.class("space-y-4")) {
@@ -21,13 +20,30 @@ struct DuctSizingView: HTML, Sendable {
Must complete all the previous sections to display duct sizing calculations.
"""
)
.hidden(when: rooms.count > 0)
.hidden(when: ductSizes.rooms.count > 0)
.attributes(.class("text-error font-bold italic mt-4"))
}
div {
button(
.class("btn btn-primary"),
.hx.get(route: .project(.detail(projectID, .pdf))),
.hx.ext("htmx-download"),
.hx.swap(.none),
.hx.indicator()
) {
span { "PDF" }
Indicator()
}
// div {
// Indicator()
// }
}
}
if rooms.count != 0 {
RoomsTable(rooms: rooms)
if ductSizes.rooms.count != 0 {
RoomsTable(rooms: ductSizes.rooms)
PageTitleRow {
PageTitle {
@@ -42,13 +58,13 @@ struct DuctSizingView: HTML, Sendable {
.tooltip("Add trunk / runout")
}
if trunks.count > 0 {
TrunkTable(trunks: trunks, rooms: rooms)
if ductSizes.trunks.count > 0 {
TrunkTable(ductSizes: ductSizes)
}
}
TrunkSizeForm(rooms: rooms, dismiss: true)
TrunkSizeForm(rooms: ductSizes.rooms, dismiss: true)
}
}

View File

@@ -5,7 +5,7 @@ import Styleguide
struct RectangularSizeForm: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
static func id(_ room: DuctSizes.RoomContainer) -> String {
let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)"
}
@@ -13,12 +13,12 @@ struct RectangularSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let id: String
let room: DuctSizing.RoomContainer
let room: DuctSizes.RoomContainer
let dismiss: Bool
init(
id: String? = nil,
room: DuctSizing.RoomContainer,
room: DuctSizes.RoomContainer,
dismiss: Bool = true
) {
self.id = Self.id(room)
@@ -40,7 +40,7 @@ struct RectangularSizeForm: HTML, Sendable {
}
var height: Int? {
room.rectangularSize?.height
room.ductSize.height
}
var body: some HTML<HTMLTag.dialog> {
@@ -54,7 +54,7 @@ struct RectangularSizeForm: HTML, Sendable {
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id))
input(.class("hidden"), .name("id"), .value(room.ductSize.rectangularID))
LabeledInput(
"Height",

View File

@@ -9,7 +9,7 @@ extension DuctSizingView {
struct RoomsTable: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.table> {
@@ -34,17 +34,17 @@ extension DuctSizingView {
struct RoomRow: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
static func id(_ room: DuctSizes.RoomContainer) -> String {
"roomRow_\(room.roomName.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let room: DuctSizing.RoomContainer
let room: DuctSizes.RoomContainer
let formID = UUID().idString
var deleteRoute: String {
guard let id = room.rectangularSize?.id else { return "" }
guard let id = room.rectangularID else { return "" }
return SiteRoute.View.router.path(
for: .project(
@@ -80,7 +80,7 @@ extension DuctSizingView {
span(.class("label")) { "Design" }
div(.class("flex justify-center")) {
Badge(number: room.designCFM.value, digits: 0)
Badge(number: room.ductSize.designCFM.value, digits: 0)
}
span(.class("label")) { "Heating" }
@@ -103,28 +103,28 @@ extension DuctSizingView {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: room.roundSize, digits: 2)
Badge(number: room.ductSize.roundSize, digits: 2)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: room.finalSize)
Badge(number: room.ductSize.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: room.flexSize)
Badge(number: room.ductSize.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = room.rectangularWidth,
let height = room.rectangularSize?.height
if let width = room.ductSize.width,
let height = room.ductSize.height
{
Badge {
span { "\(width) x \(height)" }
@@ -134,7 +134,7 @@ extension DuctSizingView {
}
div(.class("flex justify-end")) {
div(.class("join")) {
if room.rectangularSize != nil {
if room.ductSize.width != nil {
Tooltip("Delete Size", position: .bottom) {
TrashButton()
.attributes(.class("join-item btn-ghost"))
@@ -142,7 +142,7 @@ extension DuctSizingView {
.hx.delete(deleteRoute),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML),
when: room.rectangularSize != nil
when: room.ductSize.width != nil
)
}
}

View File

@@ -5,7 +5,7 @@ import Styleguide
struct TrunkSizeForm: HTML, Sendable {
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String {
static func id(_ trunk: DuctSizes.TrunkContainer? = nil) -> String {
let base = "trunkSizeForm"
guard let trunk else { return base }
return "\(base)_\(trunk.id.idString)"
@@ -13,17 +13,17 @@ struct TrunkSizeForm: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let container: DuctSizing.TrunkContainer?
let rooms: [DuctSizing.RoomContainer]
let container: DuctSizes.TrunkContainer?
let rooms: [DuctSizes.RoomContainer]
let dismiss: Bool
var trunk: DuctSizing.TrunkSize? {
var trunk: TrunkSize? {
container?.trunk
}
init(
trunk: DuctSizing.TrunkContainer? = nil,
rooms: [DuctSizing.RoomContainer],
trunk: DuctSizes.TrunkContainer? = nil,
rooms: [DuctSizes.RoomContainer],
dismiss: Bool = true
) {
self.container = trunk
@@ -56,7 +56,7 @@ struct TrunkSizeForm: HTML, Sendable {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type")) {
for type in DuctSizing.TrunkSize.TrunkType.allCases {
for type in TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized }
.attributes(.selected, when: trunk?.type == type)
}
@@ -121,8 +121,8 @@ struct TrunkSizeForm: HTML, Sendable {
}
extension Array where Element == DuctSizing.TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool {
extension Array where Element == TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizes.RoomContainer) -> Bool {
first {
$0.id == room.roomID
&& $0.registers.contains(room.roomRegister)

View File

@@ -7,11 +7,10 @@ extension DuctSizingView {
struct TrunkTable: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
let ductSizes: DuctSizes
private var sortedTrunks: [DuctSizing.TrunkContainer] {
trunks
private var sortedTrunks: [DuctSizes.TrunkContainer] {
ductSizes.trunks
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
}
@@ -29,7 +28,7 @@ extension DuctSizingView {
}
tbody {
for trunk in sortedTrunks {
TrunkRow(trunk: trunk, rooms: rooms)
TrunkRow(trunk: trunk, rooms: ductSizes.rooms)
}
}
}
@@ -41,8 +40,8 @@ extension DuctSizingView {
@Environment(ProjectViewValue.$projectID) var projectID
let trunk: DuctSizing.TrunkContainer
let rooms: [DuctSizing.RoomContainer]
let trunk: DuctSizes.TrunkContainer
let rooms: [DuctSizes.RoomContainer]
var body: some HTML<HTMLTag.tr> {
tr {
@@ -135,17 +134,18 @@ extension DuctSizingView {
}
private var registerIDS: [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()
trunk.registerIDS(rooms: rooms)
// trunk.rooms.reduce(into: []) { array, room in
// array = room.registers.reduce(into: array) { array, register in
// if let room =
// rooms
// .first(where: { $0.roomID == room.id && $0.roomRegister == register })
// {
// array.append(room.roomName)
// }
// }
// }
// .sorted()
}
}

View File

@@ -7,7 +7,7 @@ import Styleguide
struct EffectiveLengthForm: HTML, Sendable {
static func id(_ equivalentLength: EffectiveLength?) -> String {
static func id(_ equivalentLength: EquivalentLength?) -> String {
let base = "equivalentLengthForm"
guard let equivalentLength else { return base }
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
@@ -15,15 +15,15 @@ struct EffectiveLengthForm: HTML, Sendable {
let projectID: Project.ID
let dismiss: Bool
let type: EffectiveLength.EffectiveLengthType
let effectiveLength: EffectiveLength?
let type: EquivalentLength.EffectiveLengthType
let effectiveLength: EquivalentLength?
var id: String { Self.id(effectiveLength) }
init(
projectID: Project.ID,
dismiss: Bool,
type: EffectiveLength.EffectiveLengthType = .supply
type: EquivalentLength.EffectiveLengthType = .supply
) {
self.projectID = projectID
self.dismiss = dismiss
@@ -32,7 +32,7 @@ struct EffectiveLengthForm: HTML, Sendable {
}
init(
effectiveLength: EffectiveLength
effectiveLength: EquivalentLength
) {
self.dismiss = true
self.type = effectiveLength.type
@@ -55,7 +55,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepOne: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
@@ -97,7 +97,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepTwo: HTML, Sendable {
let projectID: Project.ID
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
@@ -152,7 +152,7 @@ struct EffectiveLengthForm: HTML, Sendable {
struct StepThree: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
let effectiveLength: EquivalentLength?
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
var route: String {
@@ -254,10 +254,10 @@ struct StraightLengthField: HTML, Sendable {
struct GroupField: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType
let group: EffectiveLength.Group?
let style: EquivalentLength.EffectiveLengthType
let group: EquivalentLength.FittingGroup?
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) {
init(style: EquivalentLength.EffectiveLengthType, group: EquivalentLength.FittingGroup? = nil) {
self.style = style
self.group = group
}
@@ -307,7 +307,7 @@ struct GroupField: HTML, Sendable {
struct GroupSelect: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType
let style: EquivalentLength.EffectiveLengthType
var body: some HTML {
label(.class("select")) {
@@ -328,13 +328,13 @@ struct GroupSelect: HTML, Sendable {
struct GroupTypeSelect: HTML, Sendable {
let projectID: Project.ID
let selected: EffectiveLength.EffectiveLengthType
let selected: EquivalentLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases {
for value in EquivalentLength.EffectiveLengthType.allCases {
option(
.value("\(value.rawValue)"),
) { value.title }
@@ -345,7 +345,7 @@ struct GroupTypeSelect: HTML, Sendable {
}
}
extension EffectiveLength.EffectiveLengthType {
extension EquivalentLength.EffectiveLengthType {
var title: String { rawValue.capitalized }

View File

@@ -5,9 +5,9 @@ import Styleguide
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
let effectiveLengths: [EquivalentLength]
private var sortedLengths: [EffectiveLength] {
private var sortedLengths: [EquivalentLength] {
effectiveLengths.sorted {
$0.totalEquivalentLength > $1.totalEquivalentLength
}
@@ -55,7 +55,7 @@ struct EffectiveLengthsTable: HTML, Sendable {
struct EffectiveLenghtRow: HTML, Sendable {
let effectiveLength: EffectiveLength
let effectiveLength: EquivalentLength
private var deleteRoute: SiteRoute.View {
.project(

View File

@@ -7,14 +7,14 @@ struct EffectiveLengthsView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let effectiveLengths: [EffectiveLength]
let effectiveLengths: [EquivalentLength]
var supplies: [EffectiveLength] {
var supplies: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .supply })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}
var returns: [EffectiveLength] {
var returns: [EquivalentLength] {
effectiveLengths.filter({ $0.type == .return })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}

View File

@@ -8,38 +8,34 @@ struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer
let frictionRateResponse: ManualDClient.FrictionRateResponse?
let equivalentLengths: EquivalentLength.MaxContainer
let frictionRate: FrictionRate?
private var availableStaticPressure: Double? {
frictionRateResponse?.availableStaticPressure
}
private var frictionRateDesignValue: Double? {
frictionRateResponse?.frictionRate
frictionRate?.availableStaticPressure
}
private var shouldShowBadges: Bool {
frictionRateDesignValue != nil || availableStaticPressure != nil
frictionRate != nil
}
private var badgeColor: String {
let base = "badge-info"
guard let frictionRateDesignValue else { return base }
if frictionRateDesignValue >= 0.18 || frictionRateDesignValue <= 0.02 {
guard let frictionRate = frictionRate?.value else { return base }
if frictionRate >= 0.18 || frictionRate <= 0.02 {
return "badge-error"
}
return base
}
private var showHighErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue >= 0.18
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate >= 0.18
}
private var showLowErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue <= 0.02
guard let frictionRate = frictionRate?.value else { return false }
return frictionRate <= 0.02
}
private var showNoComponentLossesError: Bool {
@@ -47,7 +43,7 @@ struct FrictionRateView: HTML, Sendable {
}
private var showIncompleteSectionsError: Bool {
availableStaticPressure == nil || frictionRateDesignValue == nil
availableStaticPressure == nil || frictionRate?.value == nil
}
private var hasAlerts: Bool {
@@ -68,11 +64,11 @@ struct FrictionRateView: HTML, Sendable {
div(.class("space-y-2 justify-end font-bold text-lg")) {
if shouldShowBadges {
if let frictionRateDesignValue {
if let frictionRate = frictionRate?.value {
LabeledContent {
span { "Friction Rate Design Value" }
} content: {
Badge(number: frictionRateDesignValue, digits: 2)
Badge(number: frictionRate, digits: 2)
.attributes(.class("\(badgeColor) badge-lg"))
.bold()
}

View File

@@ -50,8 +50,10 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
meta(.content("1024"), .name("og:image:height"))
meta(.content(keywords), .name(.keywords))
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
script(.src("/js/htmx-download.js")) {}
script(.src("/js/main.js")) {}
link(.rel(.stylesheet), .href("/css/output.css"))
link(.rel(.stylesheet), .href("/css/htmx.css"))
link(
.rel(.icon),
.href("/images/favicon.ico"),

View File

@@ -233,12 +233,12 @@ extension ManualDClient {
func frictionRate(
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
) async throws -> FrictionRateResponse? {
effectiveLength: EquivalentLength.MaxContainer
) async throws -> FrictionRate? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}
guard let totalEquivalentLength = effectiveLength.total else {
guard let totalEquivalentLength = effectiveLength.totalEquivalentLength else {
return nil
}
return try await self.frictionRate(

View File

@@ -21,7 +21,7 @@ struct RoomsView: HTML, Sendable {
}
div(.class("flex justify-end grow")) {
Tooltip("Project wide sensible heat ratio", position: .left) {
Tooltip("Set sensible heat ratio", position: .left) {
button(
.class(
"""
@@ -43,23 +43,24 @@ struct RoomsView: HTML, Sendable {
}
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
.attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil)
}
div(.class("flex items-end space-x-4 font-bold")) {
span(.class("text-lg")) { "Heating Total" }
Badge(number: rooms.heatingTotal, digits: 0)
Badge(number: rooms.totalHeatingLoad, digits: 0)
.attributes(.class("badge-error"))
}
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Total" }
Badge(number: rooms.coolingTotal, digits: 0)
Badge(number: rooms.totalCoolingLoad, digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
span(.class("text-lg")) { "Cooling Sensible" }
Badge(number: rooms.coolingSensible(shr: sensibleHeatRatio), digits: 0)
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
.attributes(.class("badge-info"))
}
}
@@ -67,7 +68,7 @@ struct RoomsView: HTML, Sendable {
SHRForm(
sensibleHeatRatio: sensibleHeatRatio,
dismiss: sensibleHeatRatio != nil
dismiss: true
)
table(.class("table table-zebra text-lg"), .id("roomsTable")) {
@@ -237,22 +238,3 @@ struct RoomsView: HTML, Sendable {
}
}
}
extension Array where Element == Room {
var heatingTotal: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
var coolingTotal: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
func coolingSensible(shr: Double?) -> Double {
let shr = shr ?? 1.0
return reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
}
}
}

View File

@@ -5,27 +5,27 @@ import ManualDCore
import Styleguide
struct TestPage: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
// let ductSizes: DuctSizes
var body: some HTML {
div(.class("overflow-auto")) {
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
Row {
h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
PlusButton()
.attributes(
.class("me-6"),
.showModal(id: TrunkSizeForm.id())
)
}
.attributes(.class("mt-6"))
div(.class("divider -mt-2")) {}
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
}
div {}
// div(.class("overflow-auto")) {
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
//
// Row {
// h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
//
// PlusButton()
// .attributes(
// .class("me-6"),
// .showModal(id: TrunkSizeForm.id())
// )
// }
// .attributes(.class("mt-6"))
//
// div(.class("divider -mt-2")) {}
//
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
// }
}
}

9
TODO.md Normal file
View File

@@ -0,0 +1,9 @@
# TODO's
- [x] Fix theme not working when selected upon signup.
- [x] Pdf generation
- [ ] Add postgres / mysql support
- [ ] Opensource / license ??
- [ ] Figure out domain to host (currently thinking ductcalc.pro)
- [ ] Logo / navbar name may have to change if it's not duct-calc.
- [ ] MainPage meta items will have to change also

View File

@@ -78,10 +78,10 @@ struct ProjectRouteTests {
let p = Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
Field("id", default: nil) { EquivalentLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()

View File

@@ -27,41 +27,41 @@ struct ManualDClientTests {
let response = try await manualD.ductSize(
.init(designCFM: 88, frictionRate: 0.06)
)
#expect(numberFormatter.string(for: response.ductulatorSize) == "6.07")
#expect(numberFormatter.string(for: response.calculatedSize) == "6.07")
#expect(response.finalSize == 7)
#expect(response.flexSize == 7)
#expect(response.velocity == 329)
}
@Test
func frictionRate() async throws {
let response = try await manualD.frictionRate(
.init(
externalStaticPressure: 0.5,
componentPressureLosses: .mock,
totalEffectiveLength: 185
)
)
#expect(numberFormatter.string(for: response.availableStaticPressure) == "0.11")
#expect(numberFormatter.string(for: response.frictionRate) == "0.06")
}
// @Test
// func frictionRate() async throws {
// let response = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 185
// )
// )
// #expect(numberFormatter.string(for: response.availableStaticPressure) == "0.11")
// #expect(numberFormatter.string(for: response.frictionRate) == "0.06")
// }
@Test
func frictionRateFails() async throws {
await #expect(throws: ManualDError.self) {
_ = try await manualD.frictionRate(
.init(
externalStaticPressure: 0.5,
componentPressureLosses: .mock,
totalEffectiveLength: 0
)
)
}
}
// @Test
// func frictionRateFails() async throws {
// await #expect(throws: ManualDError.self) {
// _ = try await manualD.frictionRate(
// .init(
// externalStaticPressure: 0.5,
// componentPressureLosses: .mock,
// totalEffectiveLength: 0
// )
// )
// }
// }
@Test
func totalEffectiveLength() async throws {
let response = try await manualD.totalEffectiveLength(
let response = try await manualD.totalEquivalentLength(
.init(
trunkLengths: [25],
runoutLengths: [10],
@@ -79,7 +79,7 @@ struct ManualDClientTests {
@Test
func equivalentRectangularDuct() async throws {
let response = try await manualD.equivalentRectangularDuct(.init(round: 7, height: 8))
let response = try await manualD.rectangularSize(.init(round: 7, height: 8))
#expect(response.height == 8)
#expect(response.width == 5)
}

View File

@@ -0,0 +1,26 @@
import Dependencies
import Foundation
import HTMLSnapshotTesting
import PdfClient
import SnapshotTesting
import Testing
@Suite(.snapshots(record: .missing))
struct PdfClientTests {
@Test
func html() async throws {
try await withDependencies {
$0.pdfClient = .liveValue
$0.uuid = .incrementing
$0.date.now = Date(timeIntervalSince1970: 1_234_567_890)
} operation: {
@Dependency(\.pdfClient) var pdfClient
let html = try await pdfClient.html(.mock())
assertSnapshot(of: html, as: .html)
}
}
}

View File

@@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Duct Calc</title>
<link rel="stylesheet" href="/css/pdf.css">
</head>
<body>
<div>
<h2>Project</h2>
<div class="flex">
<table>
<tbody>
<tr>
<td class="label">Name</td>
<td>Testy McTestface</td>
</tr>
<tr>
<td class="label">Address</td>
<td>
<p>
1234 Sesame Street
<br>
Monroe, OH 55555
</p>
</td>
</tr>
</tbody>
</table>
<table></table>
</div>
<div class="section">
<div class="flex">
<h2>Equipment</h2>
<h2>Friction Rate</h2>
</div>
<div class="flex">
<div class="container">
<div class="table-container">
<table>
<thead>
<tr class="bg-green">
<th>Equipment</th>
<th class="justify-end">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Static Pressure</td>
<td class="justify-end">0.5</td>
</tr>
<tr>
<td>Heating CFM</td>
<td class="justify-end">900</td>
</tr>
<tr>
<td>Cooling CFM</td>
<td class="justify-end">1,000</td>
</tr>
</tbody>
</table>
</div>
<div class="table-container">
<table>
<thead>
<tr class="bg-green">
<th>Friction Rate</th>
<th class="justify-end">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>evaporator-coil</td>
<td class="justify-end">0.2</td>
</tr>
<tr>
<td>filter</td>
<td class="justify-end">0.1</td>
</tr>
<tr>
<td>supply-outlet</td>
<td class="justify-end">0.03</td>
</tr>
<tr>
<td>return-grille</td>
<td class="justify-end">0.03</td>
</tr>
<tr>
<td>balancing-damper</td>
<td class="justify-end">0.03</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="section">
<h2>Duct Sizes</h2>
<table class="w-full">
<thead>
<tr class="bg-green">
<th>Name</th>
<th>Dsn CFM</th>
<th>Round Size</th>
<th>Velocity</th>
<th>Final Size</th>
<th>Flex Size</th>
<th>Height</th>
<th>Width</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bed-1</td>
<td>92</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Entry</td>
<td>88</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Entry</td>
<td>88</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Family Room</td>
<td>92</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Family Room</td>
<td>92</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Family Room</td>
<td>92</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Kitchen</td>
<td>95</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Kitchen</td>
<td>95</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Living Room</td>
<td>127</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Living Room</td>
<td>127</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Master</td>
<td>87</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
<tr>
<td>Master</td>
<td>87</td>
<td>7</td>
<td>489</td>
<td>8</td>
<td>8</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Supply Trunk / Run Outs</h2>
<table class="w-full">
<thead class="bg-green">
<tr>
<th>Name</th>
<th>Dsn CFM</th>
<th>Round Size</th>
<th>Velocity</th>
<th>Final Size</th>
<th>Flex Size</th>
<th>Height</th>
<th>Width</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td>1,000</td>
<td>18</td>
<td>987</td>
<td>20</td>
<td>20</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Return Trunk / Run Outs</h2>
<table class="w-full">
<thead class="bg-green">
<tr>
<th>Name</th>
<th>Dsn CFM</th>
<th>Round Size</th>
<th>Velocity</th>
<th>Final Size</th>
<th>Flex Size</th>
<th>Height</th>
<th>Width</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td>1,000</td>
<td>18</td>
<td>987</td>
<td>20</td>
<td>20</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Total Equivalent Lengths</h2>
<table class="w-full">
<thead>
<tr class="bg-green">
<th>Name</th>
<th>Type</th>
<th>Straight Lengths</th>
<th>Groups</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>Supply - 1</td>
<td>supply</td>
<td>
<ul>
<li>10</li>
<li>25</li>
</ul>
</td>
<td>
<table class="w-full">
<thead>
<tr class="effectiveLengthGroupHeader">
<th>Name</th>
<th>Length</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>1-a</td>
<td>20</td>
<td>1</td>
<td>20</td>
</tr>
<tr>
<td>2-b</td>
<td>30</td>
<td>1</td>
<td>30</td>
</tr>
<tr>
<td>3-a</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
<tr>
<td>12-a</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
</tbody>
</table>
</td>
<td>105</td>
</tr>
<tr>
<td>Return - 1</td>
<td>return</td>
<td>
<ul>
<li>10</li>
<li>20</li>
<li>5</li>
</ul>
</td>
<td>
<table class="w-full">
<thead>
<tr class="effectiveLengthGroupHeader">
<th>Name</th>
<th>Length</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
<tr>
<td>5-a</td>
<td>10</td>
<td>1</td>
<td>10</td>
</tr>
<tr>
<td>6-a</td>
<td>15</td>
<td>1</td>
<td>15</td>
</tr>
<tr>
<td>7-a</td>
<td>20</td>
<td>1</td>
<td>20</td>
</tr>
</tbody>
</table>
</td>
<td>80</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Register Detail</h2>
<table class="w-full">
<thead>
<tr class="bg-green">
<th>Name</th>
<th>Heating BTU</th>
<th>Cooling BTU</th>
<th>Heating CFM</th>
<th>Cooling CFM</th>
<th>Design CFM</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bed-1</td>
<td>3,913</td>
<td>2,472</td>
<td>83</td>
<td>92</td>
<td>92</td>
</tr>
<tr>
<td>Entry</td>
<td>4,142</td>
<td>1,458</td>
<td>88</td>
<td>54</td>
<td>88</td>
</tr>
<tr>
<td>Entry</td>
<td>4,142</td>
<td>1,458</td>
<td>88</td>
<td>54</td>
<td>88</td>
</tr>
<tr>
<td>Family Room</td>
<td>3,262</td>
<td>2,482</td>
<td>69</td>
<td>92</td>
<td>92</td>
</tr>
<tr>
<td>Family Room</td>
<td>3,262</td>
<td>2,482</td>
<td>69</td>
<td>92</td>
<td>92</td>
</tr>
<tr>
<td>Family Room</td>
<td>3,262</td>
<td>2,482</td>
<td>69</td>
<td>92</td>
<td>92</td>
</tr>
<tr>
<td>Kitchen</td>
<td>2,259</td>
<td>2,548</td>
<td>48</td>
<td>95</td>
<td>95</td>
</tr>
<tr>
<td>Kitchen</td>
<td>2,259</td>
<td>2,548</td>
<td>48</td>
<td>95</td>
<td>95</td>
</tr>
<tr>
<td>Living Room</td>
<td>3,776</td>
<td>3,414</td>
<td>80</td>
<td>127</td>
<td>127</td>
</tr>
<tr>
<td>Living Room</td>
<td>3,776</td>
<td>3,414</td>
<td>80</td>
<td>127</td>
<td>127</td>
</tr>
<tr>
<td>Master</td>
<td>4,101</td>
<td>1,038</td>
<td>87</td>
<td>39</td>
<td>87</td>
</tr>
<tr>
<td>Master</td>
<td>4,101</td>
<td>1,038</td>
<td>87</td>
<td>39</td>
<td>87</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Room Detail</h2>
<table class="w-full">
<thead>
<tr class="bg-green">
<th>Name</th>
<th>Heating BTU</th>
<th>Cooling Total BTU</th>
<th>Cooling Sensible BTU</th>
<th>Register Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>Bed-1</td>
<td>3,913</td>
<td>2,472</td>
<td>2,052</td>
<td>1</td>
</tr>
<tr>
<td>Entry</td>
<td>8,284</td>
<td>2,916</td>
<td>2,420</td>
<td>2</td>
</tr>
<tr>
<td>Family Room</td>
<td>9,785</td>
<td>7,446</td>
<td>6,180</td>
<td>3</td>
</tr>
<tr>
<td>Kitchen</td>
<td>4,518</td>
<td>5,096</td>
<td>4,230</td>
<td>2</td>
</tr>
<tr>
<td>Living Room</td>
<td>7,553</td>
<td>6,829</td>
<td>5,668</td>
<td>2</td>
</tr>
<tr>
<td>Master</td>
<td>8,202</td>
<td>2,076</td>
<td>1,723</td>
<td>2</td>
</tr>
<tr>
<td class="label">Totals</td>
<td class="heating label">42,255</td>
<td class="coolingTotal label">26,835</td>
<td class="coolingSensible label">22,273</td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,208 @@
import AuthClient
import DatabaseClient
import Dependencies
import Foundation
import HTMLSnapshotTesting
import Logging
import ManualDClient
import ManualDCore
import ProjectClient
import SnapshotTesting
import Testing
import ViewController
@Suite(.snapshots(record: .failed))
struct ViewControllerTests {
@Test
func login() async throws {
try await withDependencies {
$0.viewController = .liveValue
$0.authClient = .failing
} operation: {
@Dependency(\.viewController) var viewController
let login = try await viewController.view(.test(.login(.index())))
assertSnapshot(of: login, as: .html)
}
}
@Test
func signup() async throws {
try await withDependencies {
$0.viewController = .liveValue
$0.authClient = .failing
} operation: {
@Dependency(\.viewController) var viewController
let signup = try await viewController.view(.test(.login(.index())))
assertSnapshot(of: signup, as: .html)
}
}
@Test
func userProfile() async throws {
try await withDefaultDependencies {
@Dependency(\.viewController) var viewController
let html = try await viewController.view(.test(.user(.profile(.index))))
assertSnapshot(of: html, as: .html)
}
}
@Test
func projectIndex() async throws {
let project = withDependencies {
$0.uuid = .incrementing
$0.date = .constant(.mock)
} operation: {
Project.mock
}
try await withDefaultDependencies {
$0.database.projects.fetch = { _, _ in
.init(items: [project], metadata: .init(page: 1, per: 25, total: 1))
}
} operation: {
@Dependency(\.viewController) var viewController
let html = try await viewController.view(.test(.project(.index)))
assertSnapshot(of: html, as: .html)
}
}
@Test
func projectDetail() async throws {
let (
project,
rooms,
equipment,
tels,
componentLosses,
trunks
) = withDependencies {
$0.uuid = .incrementing
$0.date = .constant(.mock)
} operation: {
let project = Project.mock
let rooms = Room.mock(projectID: project.id)
let equipment = EquipmentInfo.mock(projectID: project.id)
let tels = EquivalentLength.mock(projectID: project.id)
let componentLosses = ComponentPressureLoss.mock(projectID: project.id)
let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms)
return (
project,
rooms,
equipment,
tels,
componentLosses,
trunks
)
}
try await withDefaultDependencies {
$0.database.projects.get = { _ in project }
$0.database.projects.getCompletedSteps = { _ in
.init(equipmentInfo: true, rooms: true, equivalentLength: true, frictionRate: true)
}
$0.database.projects.getSensibleHeatRatio = { _ in 0.83 }
$0.database.rooms.fetch = { _ in rooms }
$0.database.equipment.fetch = { _ in equipment }
$0.database.equivalentLengths.fetch = { _ in tels }
$0.database.equivalentLengths.fetchMax = { _ in
.init(supply: tels.first, return: tels.last)
}
$0.database.componentLosses.fetch = { _ in componentLosses }
$0.projectClient.calculateDuctSizes = { _ in
.mock(equipmentInfo: equipment, rooms: rooms, trunks: trunks)
}
} operation: {
@Dependency(\.viewController) var viewController
var html = try await viewController.view(.test(.project(.detail(project.id, .index))))
assertSnapshot(of: html, as: .html)
html = try await viewController.view(.test(.project(.detail(project.id, .rooms(.index)))))
assertSnapshot(of: html, as: .html)
html = try await viewController.view(.test(.project(.detail(project.id, .equipment(.index)))))
assertSnapshot(of: html, as: .html)
html = try await viewController.view(
.test(.project(.detail(project.id, .equivalentLength(.index)))))
assertSnapshot(of: html, as: .html)
html = try await viewController.view(
.test(.project(.detail(project.id, .frictionRate(.index)))))
assertSnapshot(of: html, as: .html)
html = try await viewController.view(
.test(.project(.detail(project.id, .ductSizing(.index)))))
assertSnapshot(of: html, as: .html)
}
}
func createUserDependencies() -> (User, User.Profile) {
withDependencies {
$0.uuid = .incrementing
$0.date = .constant(.mock)
} operation: {
let user = User.mock
let profile = User.Profile.mock(userID: user.id)
return (user, profile)
}
}
@discardableResult
func withDefaultDependencies<R>(
isolation: isolated (any Actor)? = #isolation,
_ updateDependencies: (inout DependencyValues) async throws -> Void = { _ in },
operation: () async throws -> R
) async rethrows -> R {
let (user, profile) = createUserDependencies()
return try await withDependencies {
$0.viewController = .liveValue
$0.authClient.currentUser = { user }
$0.database.userProfiles.fetch = { _ in profile }
$0.manualD = .liveValue
try await updateDependencies(&$0)
} operation: {
try await operation()
}
}
}
extension Date {
static let mock = Self(timeIntervalSince1970: 1_234_567_890)
}
extension ViewController.Request {
static func test(
_ route: SiteRoute.View,
isHtmxRequest: Bool = false,
logger: Logger = .init(label: "ViewControllerTests")
) -> Self {
.init(route: route, isHtmxRequest: isHtmxRequest, logger: logger)
}
}
extension AuthClient {
static let failing = Self(
createAndLogin: { _ in
throw TestError()
},
currentUser: {
throw TestError()
},
login: { _ in
throw TestError()
},
logout: {
throw TestError()
}
)
}
struct TestError: Error {}

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Duct Calc</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="ductcalc.com" name="og:site_name">
<meta content="Duct Calc" name="og:title">
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
<meta content="/images/mand_logo.png" name="og:image">
<meta content="/images/mand_logo.png" name="twitter:image">
<meta content="Duct Calc" name="twitter:image:alt">
<meta content="summary_large_image" name="twitter:card">
<meta content="1536" name="og:image:width">
<meta content="1024" name="og:image:height">
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<script src="/js/htmx-download.js"></script>
<script src="/js/main.js"></script>
<link rel="stylesheet" href="/css/output.css">
<link rel="stylesheet" href="/css/htmx.css">
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
</head>
<body>
<div class="flex flex-col min-h-screen min-w-full justify-between">
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
<dialog id="loginForm" class="modal modal-open">
<div class="modal-box">
<h1 class="text-2xl font-bold mb-6">Login</h1>
<form method="post" class="space-y-4">
<div>
<label class="input validator w-full"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
</g>
</svg>
<input type="email" placeholder="Email" required name="email" id="email" autofocus></label>
<div class="validator-hint hidden">Enter valid email address.</div>
</div>
<div>
<label class="input validator w-full"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<path
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
></path>
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor"></circle>
</g>
</svg>
<input type="password" placeholder="Password" required pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}" minlength="8" name="password" id="password"></label>
</div>
<div class="flex">
<button class="btn btn-secondary mt-4 w-full">Login</button>
</div>
<div class="flex justify-center"><a class="btn btn-link" href="/signup">Sign Up</a></div>
</form>
</div>
</dialog>
</main>
<div class="bottom-0 left-0 bg-error"></div>
</div>
</body>
</html>

View File

@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Duct Calc</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="ductcalc.com" name="og:site_name">
<meta content="Duct Calc" name="og:title">
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
<meta content="/images/mand_logo.png" name="og:image">
<meta content="/images/mand_logo.png" name="twitter:image">
<meta content="Duct Calc" name="twitter:image:alt">
<meta content="summary_large_image" name="twitter:card">
<meta content="1536" name="og:image:width">
<meta content="1024" name="og:image:height">
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<script src="/js/htmx-download.js"></script>
<script src="/js/main.js"></script>
<link rel="stylesheet" href="/css/output.css">
<link rel="stylesheet" href="/css/htmx.css">
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
</head>
<body>
<div class="flex flex-col min-h-screen min-w-full justify-between">
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
<div class="drawer lg:drawer-open h-full">
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
<div class="drawer-content overflow-auto">
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
<div class="flex flex-1 space-x-4 items-center">
<div class="tooltip tooltip-right" data-tip="Open sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
<div class="tooltip tooltip-right" data-tip="Home">
<a class="flex w-fit h-fit text-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
<img src="/images/mand_logo_sm.webp">
Duct Calc<span></span></a>
</div>
</div>
<div class="flex-none">
<div class="tooltip tooltip-left" data-tip="Profile"><a href="/profile" class="btn btn-square btn-ghost hover:bg-neutral hover:text-white"><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></a></div>
</div>
</nav>
<div class="p-4">
<div>
<div class="flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
p-6 w-full">
<h1 class="text-3xl font-bold">Project</h1>
<div class="tooltip tooltip-left" data-tip="Edit project">
<button class="btn btn-primary" type="button" onclick="projectForm.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
<table class="table table-zebra text-lg">
<tbody>
<tr>
<td class="label font-bold">Name</td>
<td>
<div class="flex justify-end">Testy McTestface</div>
</td>
</tr>
<tr>
<td class="label font-bold">Street Address</td>
<td>
<div class="flex justify-end">1234 Sesame Street</div>
</td>
</tr>
<tr>
<td class="label font-bold">City</td>
<td>
<div class="flex justify-end">Monroe</div>
</td>
</tr>
<tr>
<td class="label font-bold">State</td>
<td>
<div class="flex justify-end">OH</div>
</td>
</tr>
<tr>
<td class="label font-bold">Zip</td>
<td>
<div class="flex justify-end">55555</div>
</td>
</tr>
</tbody>
</table>
<dialog id="projectForm" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="projectForm.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6 ps-2">Project</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000000">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" value="Testy McTestface" placeholder="Project Name" required autofocus>
Address</label><label class="input w-full"><span class="label"></span>
<input name="streetAddress" type="text" value="1234 Sesame Street" placeholder="Street Address" required>
City</label><label class="input w-full"><span class="label"></span>
<input name="city" type="text" value="Monroe" placeholder="City" required>
State</label><label class="input w-full"><span class="label"></span>
<input name="state" type="text" value="OH" placeholder="State" required>
Zip</label><label class="input w-full"><span class="label"></span>
<input name="zipCode" type="text" value="55555" placeholder="Zip Code" required></label>
<button class="btn btn-secondary btn-block my-6" type="submit">Submit</button>
</form>
</div>
</dialog>
</div>
</div>
</div>
<div class="drawer-side is-drawer-close:overflow-visible grow">
<label for="my-drawer-1" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex grow h-full flex-col items-start bg-base-300 text-base-content
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]">
<ul class="w-full grow">
<li class="flex w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000" hx-push-url="true" hx-target="body" hx-swap="outerHTML" data-active="true">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
<div class="flex items-center justify-center"><span>Project</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
<div class="flex items-center justify-center"><span>Rooms</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="flex w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/equipment" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><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></div>
<div class="flex items-center justify-center"><span>Equipment</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/effective-lengths" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
<div class="flex items-center justify-center"><span>T.E.L.</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/friction-rate" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
<div class="flex items-center justify-center"><span>Friction Rate</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/duct-sizing" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg></div>
<div class="flex items-center justify-center"><span>Duct Sizes</span></div>
</div>
</div>
</button>
</li>
</ul>
</div>
</div>
</div>
</main>
<div class="bottom-0 left-0 bg-error">
<footer class="footer sm:footer-horizontal footer-center
bg-base-300 text-base-content p-4">
<aside>
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
</aside>
</footer>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,595 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Duct Calc</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="ductcalc.com" name="og:site_name">
<meta content="Duct Calc" name="og:title">
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
<meta content="/images/mand_logo.png" name="og:image">
<meta content="/images/mand_logo.png" name="twitter:image">
<meta content="Duct Calc" name="twitter:image:alt">
<meta content="summary_large_image" name="twitter:card">
<meta content="1536" name="og:image:width">
<meta content="1024" name="og:image:height">
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<script src="/js/htmx-download.js"></script>
<script src="/js/main.js"></script>
<link rel="stylesheet" href="/css/output.css">
<link rel="stylesheet" href="/css/htmx.css">
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
</head>
<body>
<div class="flex flex-col min-h-screen min-w-full justify-between">
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
<div class="drawer lg:drawer-open h-full">
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
<div class="drawer-content overflow-auto">
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
<div class="flex flex-1 space-x-4 items-center">
<div class="tooltip tooltip-right" data-tip="Open sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
<div class="tooltip tooltip-right" data-tip="Home">
<a class="flex w-fit h-fit text-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
<img src="/images/mand_logo_sm.webp">
Duct Calc<span></span></a>
</div>
</div>
<div class="flex-none">
<div class="tooltip tooltip-left" data-tip="Profile"><a href="/profile" class="btn btn-square btn-ghost hover:bg-neutral hover:text-white"><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></a></div>
</div>
</nav>
<div class="p-4">
<div class="flex w-full flex-col">
<div class="flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
p-6 w-full">
<div class="flex grid grid-cols-3 w-full gap-y-4">
<div class="col-span-2">
<h1 class="text-3xl font-bold">Room Loads</h1>
</div>
<div class="flex justify-end grow">
<div class="tooltip tooltip-left" data-tip="Set sensible heat ratio">
<button class="btn btn-primary text-lg font-bold py-2 " onclick="shrForm.showModal()">
<div class="flex grow justify-end items-end space-x-4">
<span>Sensible Heat Ratio</span>
<div class="badge badge-lg badge-outline"><span>0.83</span></div>
</div>
</button>
</div>
</div>
<div class="flex items-end space-x-4 font-bold">
<span class="text-lg">Heating Total</span>
<div class="badge badge-lg badge-outline badge-error"><span>42,255</span></div>
</div>
<div class="flex justify-center items-end space-x-4 my-auto font-bold">
<span class="text-lg">Cooling Total</span>
<div class="badge badge-lg badge-outline badge-success"><span>26,835</span></div>
</div>
<div class="flex grow justify-end items-end space-x-4 me-4 my-auto font-bold">
<span class="text-lg">Cooling Sensible</span>
<div class="badge badge-lg badge-outline badge-info"><span>22,273</span></div>
</div>
</div>
</div>
<dialog id="shrForm" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="shrForm.close()"> <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></button>
<h1 class="text-xl font-bold mb-6">Sensible Heat Ratio</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/update-shr" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
SHR<label class="input w-full"><span class="label"></span>
<input name="sensibleHeatRatio" type="number" value="0.83" placeholder="0.83" min="0" max="1" step="0.01" autofocus></label>
<button class="btn btn-secondary btn-block my-6" type="submit">Submit</button>
</form>
</div>
</dialog>
<table class="table table-zebra text-lg" id="roomsTable">
<thead>
<tr class="text-lg font-bold">
<th>Name</th>
<th>
<div class="flex justify-center">Heating Load</div>
</th>
<th>
<div class="flex justify-center">Cooling Total</div>
</th>
<th>
<div class="flex justify-center">Cooling Sensible</div>
</th>
<th>
<div class="flex justify-center">Register Count</div>
</th>
<th>
<div class="flex justify-end me-2">
<div class="tooltip tooltip-left" data-tip="Add Room">
<button type="button" class="btn btn-primary mx-auto tooltip-left" onclick="roomForm.showModal()"><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></button>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr id="roomRow_00000000000000000000000000000001">
<td>Bed-1</td>
<td>
<div class="flex justify-center"><span>3,913</span></div>
</td>
<td>
<div class="flex justify-center"><span>2,472</span></div>
</td>
<td>
<div class="flex justify-center"><span>2,052</span></div>
</td>
<td>
<div class="flex justify-center"><span>1</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000001" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000001.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000001" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000001.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000001" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000001">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Bed-1">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="3913.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2472.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="1"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
<tr id="roomRow_00000000000000000000000000000002">
<td>Entry</td>
<td>
<div class="flex justify-center"><span>8,284</span></div>
</td>
<td>
<div class="flex justify-center"><span>2,916</span></div>
</td>
<td>
<div class="flex justify-center"><span>2,420</span></div>
</td>
<td>
<div class="flex justify-center"><span>2</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000002" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000002.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000002" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000002.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000002" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000002">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Entry">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8284.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2916.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="2"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
<tr id="roomRow_00000000000000000000000000000003">
<td>Family Room</td>
<td>
<div class="flex justify-center"><span>9,785</span></div>
</td>
<td>
<div class="flex justify-center"><span>7,446</span></div>
</td>
<td>
<div class="flex justify-center"><span>6,180</span></div>
</td>
<td>
<div class="flex justify-center"><span>3</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000003" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000003.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000003" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000003.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000003" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000003">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Family Room">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="9785.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="7446.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="3"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
<tr id="roomRow_00000000000000000000000000000004">
<td>Kitchen</td>
<td>
<div class="flex justify-center"><span>4,518</span></div>
</td>
<td>
<div class="flex justify-center"><span>5,096</span></div>
</td>
<td>
<div class="flex justify-center"><span>4,230</span></div>
</td>
<td>
<div class="flex justify-center"><span>2</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000004" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000004.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000004" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000004.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000004" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000004">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Kitchen">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="4518.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="5096.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="2"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
<tr id="roomRow_00000000000000000000000000000005">
<td>Living Room</td>
<td>
<div class="flex justify-center"><span>7,553</span></div>
</td>
<td>
<div class="flex justify-center"><span>6,829</span></div>
</td>
<td>
<div class="flex justify-center"><span>5,668</span></div>
</td>
<td>
<div class="flex justify-center"><span>2</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000005" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000005.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000005" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000005.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000005" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000005">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Living Room">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="7553.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="6829.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="2"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
<tr id="roomRow_00000000000000000000000000000006">
<td>Master</td>
<td>
<div class="flex justify-center"><span>8,202</span></div>
</td>
<td>
<div class="flex justify-center"><span>2,076</span></div>
</td>
<td>
<div class="flex justify-center"><span>1,723</span></div>
</td>
<td>
<div class="flex justify-center"><span>2</span></div>
</td>
<td>
<div class="flex justify-end">
<div class="join">
<div class="tooltip tooltip-bottom" data-tip="Delete room">
<button type="button" class="btn btn-error join-item btn-ghost" hx-delete="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000006" hx-target="closest tr" hx-confirm="Are you sure?"><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></button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Edit room">
<button class="btn join-item btn-ghost" type="button" onclick="roomForm_00000000000000000000000000000006.showModal()">
<div class="flex">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
</div>
</button>
</div>
</div>
</div>
<dialog id="roomForm_00000000000000000000000000000006" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000006.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000006" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000006">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="Master">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8202.0">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2076.0">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="2"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</td>
</tr>
</tbody>
</table>
<dialog id="roomForm" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm.close()"> <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></button>
<h1 class="text-3xl font-bold pb-6">Room</h1>
<form class="grid grid-cols-1 gap-4" hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-target="body" hx-swap="outerHTML">
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
Name<label class="input w-full"><span class="label"></span>
<input name="name" type="text" placeholder="Name" required autofocus value="">
Heating Load</label><label class="input w-full"><span class="label"></span>
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="">
Cooling Total</label><label class="input w-full"><span class="label"></span>
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="">
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
Registers</label><label class="input w-full"><span class="label"></span>
<input name="registerCount" type="number" min="1" required value="1"></label>
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
</form>
</div>
</dialog>
</div>
</div>
</div>
<div class="drawer-side is-drawer-close:overflow-visible grow">
<label for="my-drawer-1" aria-label="close sidebar" class="drawer-overlay"></label>
<div class="flex grow h-full flex-col items-start bg-base-300 text-base-content
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]">
<ul class="w-full grow">
<li class="flex w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
<div class="flex items-center justify-center"><span>Project</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-push-url="true" hx-target="body" hx-swap="outerHTML" data-active="true">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
<div class="flex items-center justify-center"><span>Rooms</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="flex w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/equipment" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><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></div>
<div class="flex items-center justify-center"><span>Equipment</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/effective-lengths" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
<div class="flex items-center justify-center"><span>T.E.L.</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/friction-rate" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
<div class="flex items-center justify-center"><span>Friction Rate</span></div>
</div>
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
</div>
</button>
</li>
<li class="w-full">
<button class="w-full gap-1 py-2 border-b-1 border-gray-200
hover:bg-neutral data-active:bg-neutral
hover:text-white data-active:text-white
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1" hx-get="/projects/00000000-0000-0000-0000-000000000000/duct-sizing" hx-push-url="true" hx-target="body" hx-swap="outerHTML">
<div class="w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1">
<div class="items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
<div class="flex items-center justify-center"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg></div>
<div class="flex items-center justify-center"><span>Duct Sizes</span></div>
</div>
</div>
</button>
</li>
</ul>
</div>
</div>
</div>
</main>
<div class="bottom-0 left-0 bg-error">
<footer class="footer sm:footer-horizontal footer-center
bg-base-300 text-base-content p-4">
<aside>
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
</aside>
</footer>
</div>
</div>
</body>
</html>

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