Compare commits
6 Commits
58023c4dbc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2e2c424850
|
|||
|
93894e4c25
|
|||
|
bab031f241
|
|||
|
c82f20bb60
|
|||
|
458b3bd644
|
|||
|
b3c6c27a96
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ node_modules/
|
||||
tailwindcss
|
||||
.envrc
|
||||
*.pdf
|
||||
.env
|
||||
.env*
|
||||
|
||||
@@ -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
12
Public/css/htmx.css
Normal file
@@ -0,0 +1,12 @@
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.htmx-request.htmx-indicator {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,);
|
||||
|
||||
63
Public/js/htmx-download.js
Normal file
63
Public/js/htmx-download.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
Sources/EnvClient/Interface.swift
Normal file
74
Sources/EnvClient/Interface.swift
Normal 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()
|
||||
}()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,6 @@ struct PdfDocument: HTMLDocument {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Duct Sizes" }
|
||||
DuctSizesTable(rooms: request.ductSizes.rooms)
|
||||
|
||||
@@ -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: ", ")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
|
||||
extension HTMLAttribute.hx {
|
||||
@Sendable
|
||||
public static func indicator() -> HTMLAttribute {
|
||||
indicator(".hx-indicator")
|
||||
indicator(".htmx-indicator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
26
Tests/PdfClientTests/PdfClientTests.swift
Normal file
26
Tests/PdfClientTests/PdfClientTests.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
583
Tests/PdfClientTests/__Snapshots__/PdfClientTests/html.1.html
Normal file
583
Tests/PdfClientTests/__Snapshots__/PdfClientTests/html.1.html
Normal 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>
|
||||
@@ -11,7 +11,7 @@ import SnapshotTesting
|
||||
import Testing
|
||||
import ViewController
|
||||
|
||||
@Suite(.snapshots(record: .missing))
|
||||
@Suite(.snapshots(record: .failed))
|
||||
struct ViewControllerTests {
|
||||
|
||||
@Test
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user