6 Commits

35 changed files with 951 additions and 215 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ node_modules/
tailwindcss
.envrc
*.pdf
.env
.env*

View File

@@ -9,6 +9,7 @@ let package = Package(
.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"]),
@@ -79,11 +80,20 @@ 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(
@@ -96,18 +106,33 @@ let package = Package(
.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(

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

@@ -8548,10 +8548,6 @@
.italic {
font-style: italic;
}
.lining-nums {
--tw-numeric-figure: lining-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
}
.tabular-nums {
--tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);

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

@@ -36,6 +36,7 @@ struct DependenciesMiddleware: AsyncMiddleware {
// $0.dateFormatter = .liveValue
$0.viewController = viewController
$0.pdfClient = .liveValue
$0.fileClient = .live(fileIO: request.fileio)
} operation: {
try await next.respond(to: request)
}

View File

@@ -66,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
@@ -114,42 +114,6 @@ extension SiteRoute {
extension DuctSizes: Content {}
// FIX: Move
func handlePdf(_ projectID: Project.ID, on request: Request) async throws -> Response {
@Dependency(\.projectClient) var projectClient
let html = try await projectClient.toHTML(projectID)
let url = "/tmp/\(projectID)"
try await request.fileio.writeFile(.init(string: html.render()), at: "\(url).html")
let process = Process()
let standardInput = Pipe()
let standardOutput = Pipe()
process.standardInput = standardInput
process.standardOutput = standardOutput
process.executableURL = URL(fileURLWithPath: "/bin/pandoc")
process.arguments = [
"\(url).html",
"--pdf-engine=weasyprint",
"--from=html",
"--css=Public/css/pdf.css",
"-o", "\(url).pdf",
]
try process.run()
process.waitUntilExit()
let response = try await request.fileio.asyncStreamFile(at: "\(url).pdf", mediaType: .pdf) { _ in
// Remove files here.
try FileManager.default.removeItem(atPath: "\(url).pdf")
try FileManager.default.removeItem(atPath: "\(url).html")
}
response.headers.replaceOrAdd(name: .contentType, value: "application/octet-stream")
response.headers.replaceOrAdd(
name: .contentDisposition, value: "attachment; filename=Duct-Calc.pdf"
)
return response
}
@Sendable
private func siteHandler(
request: Request,
@@ -164,9 +128,10 @@ private func siteHandler(
return try await apiController.respond(route, request: request)
case .health:
return HTTPStatus.ok
// FIX: Move
// 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 handlePdf(projectID, on: request)
return try await projectClient.generatePdf(projectID)
case .view(let route):
return try await viewController.respond(route: route, request: request)
}

View File

@@ -55,6 +55,10 @@ extension DatabaseClient {
@DependencyClient
public struct Migrations: Sendable {
public var run: @Sendable () async throws -> [any AsyncMigration]
public func callAsFunction() async throws -> [any AsyncMigration] {
try await self.run()
}
}
}

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

@@ -1,6 +1,7 @@
import Dependencies
import DependenciesMacros
import Foundation
import Vapor
extension DependencyValues {
public var fileClient: FileClient {
@@ -11,19 +12,29 @@ extension DependencyValues {
@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: DependencyKey {
extension FileClient: TestDependencyKey {
public static let testValue = Self()
public static let liveValue = Self(
writeFile: { contents, path in
try contents.write(to: URL(fileURLWithPath: path), atomically: true, encoding: .utf8)
},
removeFile: { path in
try FileManager.default.removeItem(atPath: path)
}
)
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

@@ -1,10 +1,15 @@
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 }
@@ -13,8 +18,24 @@ extension DependencyValues {
@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)
public var markdown: @Sendable (Request) async throws -> String
/// 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 {
@@ -24,14 +45,38 @@ extension PdfClient: DependencyKey {
html: { request in
request.toHTML()
},
markdown: { request in
request.toMarkdown()
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
@@ -44,6 +89,10 @@ extension PdfClient {
public let frictionRate: FrictionRate
public let projectSHR: Double
var totalEquivalentLength: Double {
maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength
}
public init(
project: Project,
rooms: [Room],
@@ -66,6 +115,17 @@ extension PdfClient {
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

View File

@@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument {
}
}
}
div(.class("section")) {
h2 { "Duct Sizes" }
DuctSizesTable(rooms: request.ductSizes.rooms)

View File

@@ -1,113 +0,0 @@
import Foundation
import ManualDCore
extension PdfClient.Request {
func toMarkdown() -> String {
var retval = """
# Duct Calc
**Name:** \(project.name)
**Address:** \(project.streetAddress)
\(project.city), \(project.state) \(project.zipCode)
## Equipment
| | Value |
|:----------------|:--------------------------------|
| Static Pressure | \(equipmentInfo.staticPressure.string()) |
| Heating CFM | \(equipmentInfo.heatingCFM.string()) |
| Cooling CFM | \(equipmentInfo.coolingCFM.string()) |
## Friction Rate
| Component Loss | Value |
|:----------------|:--------------------------------|
"""
for row in componentLosses {
retval += "\(componentLossRow(row))\n"
}
retval += """
| Results | Value |
|:-----------------|:---------------------------------|
| Available Static Pressure | \(frictionRate.availableStaticPressure.string()) |
| Total Equivalent Length | \(totalEquivalentLength.string()) |
| Friction Rate Design Value | \(frictionRate.value.string()) |
## Duct Sizes
| Register | Dsn CFM | Round Size | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------|:----------------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.rooms {
retval += "\(registerRow(row))\n"
}
retval += """
## Trunk Sizes
### Supply Trunks
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .supply }) {
retval += "\(trunkRow(row))\n"
}
retval += """
### Return Trunks / Run Outs
| Name | Associated Supplies | Dsn CFM | Velocity | Final Size | Flex Size | Height | Width |
|:---------|:--------------------|:--------|:---------|:-----------|:----------|:-------|:------|
"""
for row in ductSizes.trunks.filter({ $0.type == .return }) {
retval += "\(trunkRow(row))\n"
}
return retval
}
func registerRow(_ row: DuctSizes.RoomContainer) -> String {
return """
| \(row.roomName) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func trunkRow(_ row: DuctSizes.TrunkContainer) -> String {
return """
| \(row.name ?? "") | \(associatedSupplyString(row)) | \(row.designCFM.value.string(digits: 0)) | \(row.roundSize.string()) | \(row.velocity.string()) | \(row.finalSize.string()) | \(row.flexSize.string()) | \(row.ductSize.height?.string() ?? "") | \(row.width?.string() ?? "") |
"""
}
func componentLossRow(_ row: ComponentPressureLoss) -> String {
return """
| \(row.name) | \(row.value.string()) |
"""
}
var totalEquivalentLength: Double {
maxSupplyTEL.totalEquivalentLength + maxReturnTEL.totalEquivalentLength
}
func associatedSupplyString(_ row: DuctSizes.TrunkContainer) -> String {
row.associatedSupplyString(rooms: ductSizes.rooms)
}
}
extension DuctSizes.TrunkContainer {
func associatedSupplyString(rooms: [DuctSizes.RoomContainer]) -> String {
self.registerIDS(rooms: rooms)
.joined(separator: ", ")
}
}

View File

@@ -3,6 +3,7 @@ import DependenciesMacros
import Elementary
import ManualDClient
import ManualDCore
import Vapor
extension DependencyValues {
public var projectClient: ProjectClient {
@@ -27,10 +28,7 @@ public struct ProjectClient: Sendable {
@Sendable (User.ID, Project.Create) async throws -> CreateProjectResponse
public var frictionRate: @Sendable (Project.ID) async throws -> FrictionRateResponse
// FIX: Name to something to do with generating a pdf, just experimenting now.
public var toMarkdown: @Sendable (Project.ID) async throws -> String
public var toHTML: @Sendable (Project.ID) async throws -> (any HTML & Sendable)
public var generatePdf: @Sendable (Project.ID) async throws -> Response
}
extension ProjectClient: TestDependencyKey {

View File

@@ -1,5 +1,6 @@
import DatabaseClient
import Dependencies
import FileClient
import Logging
import ManualDClient
import ManualDCore
@@ -11,6 +12,7 @@ extension ProjectClient: DependencyKey {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@Dependency(\.pdfClient) var pdfClient
@Dependency(\.fileClient) var fileClient
return .init(
calculateDuctSizes: { projectID in
@@ -35,11 +37,25 @@ extension ProjectClient: DependencyKey {
frictionRate: { projectID in
try await manualD.frictionRate(projectID: projectID)
},
toMarkdown: { projectID in
try await pdfClient.markdown(database.makePdfRequest(projectID))
},
toHTML: { projectID in
try await pdfClient.html(database.makePdfRequest(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
}
)
}
@@ -48,31 +64,6 @@ extension ProjectClient: DependencyKey {
extension DatabaseClient {
// fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
// @Dependency(\.manualD) var manualD
//
// guard let project = try await projects.get(projectID) else {
// throw ProjectClientError("Project not found. id: \(projectID)")
// }
// let frictionRateResponse = try await manualD.frictionRate(projectID: projectID)
// guard let frictionRate = frictionRateResponse.frictionRate else {
// throw ProjectClientError("Friction rate not found. id: \(projectID)")
// }
// let (ductSizes, sharedInfo, rooms) = try await calculateDuctSizes(projectID: projectID)
//
// return .init(
// project: project,
// rooms: rooms,
// componentLosses: frictionRateResponse.componentLosses,
// ductSizes: ductSizes,
// equipmentInfo: sharedInfo.equipmentInfo,
// maxSupplyTEL: sharedInfo.maxSupplyLength,
// maxReturnTEL: sharedInfo.maxReturnLenght,
// frictionRate: frictionRate,
// projectSHR: sharedInfo.projectSHR
// )
// }
fileprivate func makePdfRequest(_ projectID: Project.ID) async throws -> PdfClient.Request {
@Dependency(\.manualD) var manualD

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

@@ -32,7 +32,8 @@ extension ViewController.Request {
// // TestPage(trunks: result.trunks, rooms: result.rooms)
// }
// }
return try! await pdfClient.html(.mock())
// return try! await pdfClient.html(.mock())
return EmptyHTML()
case .login(let route):
switch route {
case .index(let next):
@@ -195,7 +196,9 @@ extension SiteRoute.View.ProjectRoute {
case .pdf:
// FIX: This should return a pdf to download or be wrapped in a
// result view.
return try! await projectClient.toHTML(projectID)
// 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)
}

View File

@@ -24,11 +24,20 @@ struct DuctSizingView: HTML, Sendable {
.attributes(.class("text-error font-bold italic mt-4"))
}
a(
.class("btn btn-primary"),
.href(route: .project(.detail(projectID, .pdf)))
) {
"PDF"
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()
// }
}
}

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

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

@@ -11,7 +11,7 @@ import SnapshotTesting
import Testing
import ViewController
@Suite(.snapshots(record: .missing))
@Suite(.snapshots(record: .failed))
struct ViewControllerTests {
@Test

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">
@@ -55,7 +57,9 @@ p-6 w-full">
<p>Must complete all the previous sections to display duct sizing calculations.</p>
</div>
</div>
PDF<a class="btn btn-primary" href="/projects/00000000-0000-0000-0000-000000000000/pdf"></a>
<div>
<button class="btn btn-primary" hx-get="/projects/00000000-0000-0000-0000-000000000000/pdf" hx-ext="htmx-download" hx-swap="none" hx-indicator=".htmx-indicator"><span>PDF</span><span class="loading loading-spinner loading-lg htmx-indicator"></span></button>
</div>
</div>
<table class="table table-zebra text-lg">
<thead>

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -16,8 +16,10 @@
<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">

View File

@@ -66,6 +66,8 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
# libxml2 \
sqlite3 \
curl \
pandoc \
weasyprint \
&& rm -r /var/lib/apt/lists/*
# Create a vapor user and group with /app as its home directory

View File

@@ -18,6 +18,8 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
build-essential \
curl \
wkhtmltopdf \
pandoc \
weasyprint \
&& rm -r /var/lib/apt/lists/*
# Set up a build area