diff --git a/.gitignore b/.gitignore index 86312e5..bfd357a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ DerivedData/ node_modules/ tailwindcss .envrc +*.pdf +.env +.env* diff --git a/Package.resolved b/Package.resolved index 5858eb1..8cc2eb4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -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", diff --git a/Package.swift b/Package.swift index d1c899c..45082d0 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,9 @@ 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"]), .library(name: "ProjectClient", targets: ["ProjectClient"]), .library(name: "ManualDCore", targets: ["ManualDCore"]), @@ -22,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"), @@ -76,19 +80,59 @@ let package = Package( .product(name: "Vapor", package: "vapor"), ] ), + .target( - name: "PdfClient", + name: "EnvClient", dependencies: [ - .target(name: "ManualDCore"), .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( @@ -134,6 +178,7 @@ let package = Package( dependencies: [ .target(name: "AuthClient"), .target(name: "DatabaseClient"), + .target(name: "PdfClient"), .target(name: "ProjectClient"), .target(name: "ManualDClient"), .target(name: "ManualDCore"), @@ -145,5 +190,15 @@ let package = Package( .product(name: "Vapor", package: "vapor"), ] ), + .testTarget( + name: "ViewControllerTests", + dependencies: [ + .target(name: "ViewController"), + .target(name: "HTMLSnapshotTesting"), + ], + resources: [ + .copy("__Snapshots__") + ] + ), ] ) diff --git a/Public/css/htmx.css b/Public/css/htmx.css new file mode 100644 index 0000000..108aed9 --- /dev/null +++ b/Public/css/htmx.css @@ -0,0 +1,12 @@ +.htmx-indicator { + display: none; +} + +.htmx-request .htmx-indicator { + display: inline; +} + +.htmx-request.htmx-indicator { + display: inline; +} + diff --git a/Public/css/main.css b/Public/css/main.css index a86c959..f5a9b4c 100644 --- a/Public/css/main.css +++ b/Public/css/main.css @@ -2,4 +2,3 @@ @plugin "daisyui" { themes: all; } - diff --git a/Public/css/pdf.css b/Public/css/pdf.css new file mode 100644 index 0000000..2680d0d --- /dev/null +++ b/Public/css/pdf.css @@ -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; +} diff --git a/Public/js/htmx-download.js b/Public/js/htmx-download.js new file mode 100644 index 0000000..1da50d7 --- /dev/null +++ b/Public/js/htmx-download.js @@ -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; + } + }, +}); diff --git a/Sources/ApiController/Live.swift b/Sources/ApiController/Live.swift index 097586b..62f4db5 100644 --- a/Sources/ApiController/Live.swift +++ b/Sources/ApiController/Live.swift @@ -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() diff --git a/Sources/App/Middleware/DependenciesMiddleware.swift b/Sources/App/Middleware/DependenciesMiddleware.swift index 23b676c..8a16843 100644 --- a/Sources/App/Middleware/DependenciesMiddleware.swift +++ b/Sources/App/Middleware/DependenciesMiddleware.swift @@ -3,6 +3,7 @@ import AuthClient import DatabaseClient import Dependencies import ManualDCore +import PdfClient import Vapor import ViewController @@ -34,6 +35,8 @@ struct DependenciesMiddleware: AsyncMiddleware { $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) } diff --git a/Sources/App/Middleware/ViewRoute+middleware.swift b/Sources/App/Middleware/ViewRoute+middleware.swift index c3cbadd..4f79011 100644 --- a/Sources/App/Middleware/ViewRoute+middleware.swift +++ b/Sources/App/Middleware/ViewRoute+middleware.swift @@ -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 } } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 71f4d6e..53479b1 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -128,12 +128,11 @@ private func siteHandler( 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): - // FIX: Remove. - if route == .test { - let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")! - return try await projectClient.calculateDuctSizes(projectID) - } return try await viewController.respond(route: route, request: request) } } diff --git a/Sources/DatabaseClient/Projects.swift b/Sources/DatabaseClient/Projects.swift index 0b746f2..054ce97 100644 --- a/Sources/DatabaseClient/Projects.swift +++ b/Sources/DatabaseClient/Projects.swift @@ -9,6 +9,7 @@ extension DatabaseClient { public struct Projects: Sendable { public var create: @Sendable (User.ID, Project.Create) async throws -> Project public var delete: @Sendable (Project.ID) async throws -> Void + public var detail: @Sendable (Project.ID) async throws -> Project.Detail? public var get: @Sendable (Project.ID) async throws -> Project? public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double? @@ -33,6 +34,44 @@ extension DatabaseClient.Projects: TestDependencyKey { } try await model.delete(on: database) }, + detail: { id in + guard + let model = try await ProjectModel.query(on: database) + .with(\.$componentLosses) + .with(\.$equipment) + .with(\.$equivalentLengths) + .with(\.$rooms) + .with( + \.$trunks, + { trunk in + trunk.with( + \.$rooms, + { + $0.with(\.$room) + } + ) + } + ) + .filter(\.$id == id) + .first() + else { + throw NotFoundError() + } + + // TODO: Different error ?? + guard let equipmentInfo = model.equipment else { return nil } + + let trunks = try model.trunks.toDTO() + + return try .init( + project: model.toDTO(), + componentLosses: model.componentLosses.map { try $0.toDTO() }, + equipmentInfo: equipmentInfo.toDTO(), + equivalentLengths: model.equivalentLengths.map { try $0.toDTO() }, + rooms: model.rooms.map { try $0.toDTO() }, + trunks: trunks + ) + }, get: { id in try await ProjectModel.find(id, on: database).map { try $0.toDTO() } }, @@ -248,6 +287,18 @@ final class ProjectModel: Model, @unchecked Sendable { @Children(for: \.$project) var componentLosses: [ComponentLossModel] + @OptionalChild(for: \.$project) + var equipment: EquipmentModel? + + @Children(for: \.$project) + var equivalentLengths: [EffectiveLengthModel] + + @Children(for: \.$project) + var rooms: [RoomModel] + + @Children(for: \.$project) + var trunks: [TrunkModel] + @Parent(key: "userID") var user: UserModel diff --git a/Sources/DatabaseClient/TrunkSizes.swift b/Sources/DatabaseClient/TrunkSizes.swift index 8a31fea..80867c4 100644 --- a/Sources/DatabaseClient/TrunkSizes.swift +++ b/Sources/DatabaseClient/TrunkSizes.swift @@ -41,7 +41,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 +62,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,7 +93,7 @@ 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() } ) } @@ -201,10 +210,10 @@ final class TrunkRoomModel: Model, @unchecked Sendable { self.type = type.rawValue } - func toDTO(on database: any Database) async throws -> 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 @@ -251,18 +260,22 @@ final class TrunkModel: Model, @unchecked Sendable { self.name = name } - func toDTO(on database: any Database) async 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) - } + 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( @@ -340,17 +353,17 @@ final class TrunkModel: Model, @unchecked Sendable { extension Array where Element == TrunkModel { - func toDTO(on database: any Database) async throws -> [TrunkSize] { - try await withThrowingTaskGroup(of: 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: [TrunkSize]()) { - $0.append($1) - } + return try reduce(into: [TrunkSize]()) { + $0.append(try $1.toDTO()) } } + // } } diff --git a/Sources/EnvClient/Interface.swift b/Sources/EnvClient/Interface.swift new file mode 100644 index 0000000..b673dfa --- /dev/null +++ b/Sources/EnvClient/Interface.swift @@ -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() +}() diff --git a/Sources/FileClient/Interface.swift b/Sources/FileClient/Interface.swift new file mode 100644 index 0000000..359f8ea --- /dev/null +++ b/Sources/FileClient/Interface.swift @@ -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() + } + } + ) + } +} diff --git a/Sources/HTMLSnapshotTesting/Snapshotting.swift b/Sources/HTMLSnapshotTesting/Snapshotting.swift new file mode 100644 index 0000000..fa11701 --- /dev/null +++ b/Sources/HTMLSnapshotTesting/Snapshotting.swift @@ -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 + } +} diff --git a/Sources/ManualDCore/ComponentPressureLosses.swift b/Sources/ManualDCore/ComponentPressureLosses.swift index f316095..b719e70 100644 --- a/Sources/ManualDCore/ComponentPressureLosses.swift +++ b/Sources/ManualDCore/ComponentPressureLosses.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation public struct ComponentPressureLoss: Codable, Equatable, Identifiable, Sendable { @@ -89,7 +90,61 @@ public typealias ComponentPressureLosses = [String: Double] } } + extension Array where Element == ComponentPressureLoss { + public static func mock(projectID: Project.ID) -> Self { + ComponentPressureLoss.mock(projectID: projectID) + } + } + extension ComponentPressureLoss { + public static func mock(projectID: Project.ID) -> [Self] { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + + return [ + .init( + id: uuid(), + projectID: projectID, + name: "evaporator-coil", + value: 0.2, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "filter", + value: 0.1, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "supply-outlet", + value: 0.03, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "return-grille", + value: 0.03, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "balancing-damper", + value: 0.03, + createdAt: now, + updatedAt: now + ), + ] + } + public static var mock: [Self] { [ .init( diff --git a/Sources/ManualDCore/DuctSizes.swift b/Sources/ManualDCore/DuctSizes.swift index e4b1c8a..14eb011 100644 --- a/Sources/ManualDCore/DuctSizes.swift +++ b/Sources/ManualDCore/DuctSizes.swift @@ -130,5 +130,127 @@ extension DuctSizes { public subscript(dynamicMember keyPath: KeyPath) -> 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 diff --git a/Sources/ManualDCore/EffectiveLength.swift b/Sources/ManualDCore/EffectiveLength.swift index 7056715..86c42d2 100644 --- a/Sources/ManualDCore/EffectiveLength.swift +++ b/Sources/ManualDCore/EffectiveLength.swift @@ -142,6 +142,44 @@ extension Array where Element == EffectiveLength.Group { #if DEBUG extension EffectiveLength { + + public static func mock(projectID: Project.ID) -> [Self] { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + + return [ + .init( + id: uuid(), + projectID: projectID, + name: "Supply - 1", + type: .supply, + straightLengths: [10, 25], + groups: [ + .init(group: 1, letter: "a", value: 20), + .init(group: 2, letter: "b", value: 30, quantity: 1), + .init(group: 3, letter: "a", value: 10, quantity: 1), + .init(group: 12, letter: "a", value: 10, quantity: 1), + ], + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Return - 1", + type: .return, + straightLengths: [10, 20, 5], + groups: [ + .init(group: 5, letter: "a", value: 10), + .init(group: 6, letter: "a", value: 15, quantity: 1), + .init(group: 7, letter: "a", value: 20, quantity: 1), + ], + createdAt: now, + updatedAt: now + ), + ] + } + public static let mocks: [Self] = [ .init( id: UUID(0), diff --git a/Sources/ManualDCore/EquipmentInfo.swift b/Sources/ManualDCore/EquipmentInfo.swift index e2b6fb6..a4d9c8e 100644 --- a/Sources/ManualDCore/EquipmentInfo.swift +++ b/Sources/ManualDCore/EquipmentInfo.swift @@ -70,6 +70,21 @@ extension EquipmentInfo { #if DEBUG extension EquipmentInfo { + + public static func mock(projectID: Project.ID) -> Self { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + + return .init( + id: uuid(), + projectID: projectID, + heatingCFM: 900, + coolingCFM: 1000, + createdAt: now, + updatedAt: now + ) + } + public static let mock = Self( id: UUID(0), projectID: UUID(0), diff --git a/Sources/ManualDCore/FrictionRate.swift b/Sources/ManualDCore/FrictionRate.swift index 7949765..83f1be7 100644 --- a/Sources/ManualDCore/FrictionRate.swift +++ b/Sources/ManualDCore/FrictionRate.swift @@ -3,6 +3,7 @@ public struct FrictionRate: Codable, Equatable, Sendable { public let availableStaticPressure: Double public let value: Double + public var hasErrors: Bool { error != nil } public init( availableStaticPressure: Double, @@ -11,4 +12,46 @@ public struct FrictionRate: Codable, Equatable, Sendable { self.availableStaticPressure = availableStaticPressure self.value = value } + + public var error: FrictionRateError? { + if value >= 0.18 { + return .init( + "Friction rate should be lower than 0.18", + resolutions: [ + "Decrease the blower speed", + "Decrease the blower size", + "Increase the Total Equivalent Length", + ] + ) + } else if value <= 0.02 { + return .init( + "Friction rate should be higher than 0.02", + resolutions: [ + "Increase the blower speed", + "Increase the blower size", + "Decrease the Total Equivalent Length", + ] + ) + } + return nil + } } + +public struct FrictionRateError: Error, Equatable, Sendable { + public let reason: String + public let resolutions: [String] + + public init( + _ reason: String, + resolutions: [String] + ) { + self.reason = reason + self.resolutions = resolutions + } +} + +#if DEBUG + extension FrictionRate { + public static let mock = Self(availableStaticPressure: 0.21, value: 0.11) + } +#endif diff --git a/Sources/ManualDCore/Numbers+string.swift b/Sources/ManualDCore/Numbers+string.swift new file mode 100644 index 0000000..c633eb5 --- /dev/null +++ b/Sources/ManualDCore/Numbers+string.swift @@ -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)! +} diff --git a/Sources/ManualDCore/Project.swift b/Sources/ManualDCore/Project.swift index eeb49f9..5ef9d36 100644 --- a/Sources/ManualDCore/Project.swift +++ b/Sources/ManualDCore/Project.swift @@ -84,6 +84,32 @@ extension Project { } } + public struct Detail: Codable, Equatable, Sendable { + + public let project: Project + public let componentLosses: [ComponentPressureLoss] + public let equipmentInfo: EquipmentInfo + public let equivalentLengths: [EffectiveLength] + public let rooms: [Room] + public let trunks: [TrunkSize] + + public init( + project: Project, + componentLosses: [ComponentPressureLoss], + equipmentInfo: EquipmentInfo, + equivalentLengths: [EffectiveLength], + rooms: [Room], + trunks: [TrunkSize] + ) { + self.project = project + self.componentLosses = componentLosses + self.equipmentInfo = equipmentInfo + self.equivalentLengths = equivalentLengths + self.rooms = rooms + self.trunks = trunks + } + } + public struct Update: Codable, Equatable, Sendable { public let name: String? @@ -114,16 +140,22 @@ extension Project { #if DEBUG extension Project { - public static let mock = Self( - id: UUID(0), - name: "Testy McTestface", - streetAddress: "1234 Sesame Street", - city: "Monroe", - state: "OH", - zipCode: "55555", - createdAt: Date(), - updatedAt: Date() - ) + + public static var mock: Self { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + + return .init( + id: uuid(), + name: "Testy McTestface", + streetAddress: "1234 Sesame Street", + city: "Monroe", + state: "OH", + zipCode: "55555", + createdAt: now, + updatedAt: now + ) + } } #endif diff --git a/Sources/ManualDCore/Room.swift b/Sources/ManualDCore/Room.swift index ad886b2..de12d32 100644 --- a/Sources/ManualDCore/Room.swift +++ b/Sources/ManualDCore/Room.swift @@ -171,6 +171,86 @@ extension Array where Element == Room { updatedAt: Date() ), ] + + public static func mock(projectID: Project.ID) -> [Self] { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + + return [ + .init( + id: uuid(), + projectID: projectID, + name: "Bed-1", + heatingLoad: 3913, + coolingTotal: 2472, + coolingSensible: nil, + registerCount: 1, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Entry", + heatingLoad: 8284, + coolingTotal: 2916, + coolingSensible: nil, + registerCount: 2, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Family Room", + heatingLoad: 9785, + coolingTotal: 7446, + coolingSensible: nil, + registerCount: 3, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Kitchen", + heatingLoad: 4518, + coolingTotal: 5096, + coolingSensible: nil, + registerCount: 2, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Living Room", + heatingLoad: 7553, + coolingTotal: 6829, + coolingSensible: nil, + registerCount: 2, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + .init( + id: uuid(), + projectID: projectID, + name: "Master", + heatingLoad: 8202, + coolingTotal: 2076, + coolingSensible: nil, + registerCount: 2, + rectangularSizes: nil, + createdAt: now, + updatedAt: now + ), + ] + } } #endif diff --git a/Sources/ManualDCore/Routes/ApiRoute.swift b/Sources/ManualDCore/Routes/ApiRoute.swift index 97ae36f..afaa5fe 100644 --- a/Sources/ManualDCore/Routes/ApiRoute.swift +++ b/Sources/ManualDCore/Routes/ApiRoute.swift @@ -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 diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index ca5be24..8414ec3 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -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 } diff --git a/Sources/ManualDCore/TrunkSize.swift b/Sources/ManualDCore/TrunkSize.swift index 819b7f4..ad1eaa4 100644 --- a/Sources/ManualDCore/TrunkSize.swift +++ b/Sources/ManualDCore/TrunkSize.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation // Represents the database model. @@ -71,7 +72,6 @@ extension TrunkSize { } } - // TODO: Make registers non-optional public struct RoomProxy: Codable, Equatable, Identifiable, Sendable { public var id: Room.ID { room.id } @@ -91,3 +91,24 @@ extension TrunkSize { 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 diff --git a/Sources/ManualDCore/User.swift b/Sources/ManualDCore/User.swift index 2cf9685..f0869a8 100644 --- a/Sources/ManualDCore/User.swift +++ b/Sources/ManualDCore/User.swift @@ -65,3 +65,15 @@ extension User { } } } + +#if DEBUG + + extension User { + public static var mock: Self { + @Dependency(\.uuid) var uuid + @Dependency(\.date.now) var now + return .init(id: uuid(), email: "testy@example.com", createdAt: now, updatedAt: now) + } + } + +#endif diff --git a/Sources/ManualDCore/UserProfile.swift b/Sources/ManualDCore/UserProfile.swift index 84baed3..cae86a2 100644 --- a/Sources/ManualDCore/UserProfile.swift +++ b/Sources/ManualDCore/UserProfile.swift @@ -1,3 +1,4 @@ +import Dependencies import Foundation extension User { @@ -113,3 +114,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 diff --git a/Sources/PdfClient/Interface.swift b/Sources/PdfClient/Interface.swift index e495f1a..6aa0b05 100644 --- a/Sources/PdfClient/Interface.swift +++ b/Sources/PdfClient/Interface.swift @@ -1,47 +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 { - public var markdown: @Sendable (Request) async throws -> String + /// 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: TestDependencyKey { +extension PdfClient: DependencyKey { public static let testValue = Self() + + public static let liveValue = Self( + html: { request in + request.toHTML() + }, + generatePdf: { projectID, html in + @Dependency(\.fileClient) var fileClient + @Dependency(\.env) var env + + let envVars = try env() + let baseUrl = "/tmp/\(projectID)" + try await fileClient.writeFile(html.render(), "\(baseUrl).html") + + let process = Process() + let standardInput = Pipe() + let standardOutput = Pipe() + process.standardInput = standardInput + process.standardOutput = standardOutput + process.executableURL = URL(fileURLWithPath: envVars.pandocPath) + process.arguments = [ + "\(baseUrl).html", + "--pdf-engine=\(envVars.pdfEngine)", + "--from=html", + "--css=Public/css/pdf.css", + "--output=\(baseUrl).pdf", + ] + try process.run() + process.waitUntilExit() + + return .init(htmlPath: "\(baseUrl).html", pdfPath: "\(baseUrl).pdf") + + } + ) } extension PdfClient { - + /// Container for the data required to generate a pdf for a given project. public struct Request: Codable, Equatable, Sendable { public let project: Project + public let rooms: [Room] public let componentLosses: [ComponentPressureLoss] public let ductSizes: DuctSizes public let equipmentInfo: EquipmentInfo public let maxSupplyTEL: EffectiveLength public let maxReturnTEL: EffectiveLength - public let designFrictionRate: FrictionRate + public let frictionRate: FrictionRate public let projectSHR: Double + var totalEquivalentLength: Double { + maxReturnTEL.totalEquivalentLength + maxSupplyTEL.totalEquivalentLength + } + public init( project: Project, + rooms: [Room], componentLosses: [ComponentPressureLoss], ductSizes: DuctSizes, equipmentInfo: EquipmentInfo, maxSupplyTEL: EffectiveLength, maxReturnTEL: EffectiveLength, - designFrictionRate: FrictionRate, + 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.designFrictionRate = designFrictionRate + self.frictionRate = frictionRate self.projectSHR = projectSHR } } + + public struct Response: Equatable, Sendable { + + public let htmlPath: String + public let pdfPath: String + + public init(htmlPath: String, pdfPath: String) { + self.htmlPath = htmlPath + self.pdfPath = pdfPath + } + } } + +#if DEBUG + extension PdfClient.Request { + public static func mock(project: Project = .mock) -> Self { + let rooms = Room.mock(projectID: project.id) + let trunks = TrunkSize.mock(projectID: project.id, rooms: rooms) + let equipmentInfo = EquipmentInfo.mock(projectID: project.id) + let equivalentLengths = EffectiveLength.mock(projectID: project.id) + + return .init( + project: project, + rooms: rooms, + componentLosses: ComponentPressureLoss.mock(projectID: project.id), + ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks), + equipmentInfo: equipmentInfo, + maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!, + maxReturnTEL: equivalentLengths.first { $0.type == .return }!, + frictionRate: .mock, + projectSHR: 0.83 + ) + + } + } +#endif diff --git a/Sources/PdfClient/Request+html.swift b/Sources/PdfClient/Request+html.swift new file mode 100644 index 0000000..7dec256 --- /dev/null +++ b/Sources/PdfClient/Request+html.swift @@ -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")) + } + } + + } + +} diff --git a/Sources/PdfClient/Request+markdown.swift b/Sources/PdfClient/Request+markdown.swift deleted file mode 100644 index 6ec90d9..0000000 --- a/Sources/PdfClient/Request+markdown.swift +++ /dev/null @@ -1,42 +0,0 @@ -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) | - | Heating CFM | \(equipmentInfo.heatingCFM) | - | Cooling CFM | \(equipmentInfo.coolingCFM) | - - ## Friction Rate - - | | Value | - |-----------------|---------------------------------| - - """ - for row in componentLosses { - retval = """ - \(retval) - \(componentLossRow(row)) - """ - } - - return retval - } - - func componentLossRow(_ row: ComponentPressureLoss) -> String { - return """ - | \(row.name) | \(row.value) | - """ - } -} diff --git a/Sources/PdfClient/Views/DuctSizeTable.swift b/Sources/PdfClient/Views/DuctSizeTable.swift new file mode 100644 index 0000000..070581a --- /dev/null +++ b/Sources/PdfClient/Views/DuctSizeTable.swift @@ -0,0 +1,37 @@ +import Elementary +import ManualDCore + +struct DuctSizesTable: HTML, Sendable { + let rooms: [DuctSizes.RoomContainer] + + var body: some HTML { + 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() ?? "" } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/EquipmentTable.swift b/Sources/PdfClient/Views/EquipmentTable.swift new file mode 100644 index 0000000..74f3cd0 --- /dev/null +++ b/Sources/PdfClient/Views/EquipmentTable.swift @@ -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 { + + 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() } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/EquivalentLengthTable.swift b/Sources/PdfClient/Views/EquivalentLengthTable.swift new file mode 100644 index 0000000..a80de05 --- /dev/null +++ b/Sources/PdfClient/Views/EquivalentLengthTable.swift @@ -0,0 +1,68 @@ +import Elementary +import ManualDCore + +struct EffectiveLengthsTable: HTML, Sendable { + let effectiveLengths: [EffectiveLength] + + var body: some HTML { + table { + thead { + tr(.class("bg-green")) { + th { "Name" } + th { "Type" } + th { "Straight Lengths" } + th { "Groups" } + th { "Total" } + } + } + tbody { + for row in effectiveLengths { + tr { + td { row.name } + td { row.type.rawValue } + td { + ul { + for length in row.straightLengths { + li { length.string() } + } + } + } + td { + EffectiveLengthGroupTable(groups: row.groups) + .attributes(.class("w-full")) + } + td { row.totalEquivalentLength.string(digits: 0) } + } + } + } + } + } + +} + +struct EffectiveLengthGroupTable: HTML, Sendable { + let groups: [EffectiveLength.Group] + + var body: some HTML { + 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) } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/FrictionRateTable.swift b/Sources/PdfClient/Views/FrictionRateTable.swift new file mode 100644 index 0000000..0739de7 --- /dev/null +++ b/Sources/PdfClient/Views/FrictionRateTable.swift @@ -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 { + 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() } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/ProjectTable.swift b/Sources/PdfClient/Views/ProjectTable.swift new file mode 100644 index 0000000..d25a10e --- /dev/null +++ b/Sources/PdfClient/Views/ProjectTable.swift @@ -0,0 +1,33 @@ +import Elementary +import ManualDCore + +struct ProjectTable: HTML, Sendable { + let project: Project + + var body: some HTML { + 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)" + } +} diff --git a/Sources/PdfClient/Views/RegisterTable.swift b/Sources/PdfClient/Views/RegisterTable.swift new file mode 100644 index 0000000..41f134a --- /dev/null +++ b/Sources/PdfClient/Views/RegisterTable.swift @@ -0,0 +1,33 @@ +import Elementary +import ManualDCore + +struct RegisterDetailTable: HTML, Sendable { + let rooms: [DuctSizes.RoomContainer] + + var body: some HTML { + 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) } + } + } + } + } + } +} diff --git a/Sources/PdfClient/Views/RoomTable.swift b/Sources/PdfClient/Views/RoomTable.swift new file mode 100644 index 0000000..2cd07bc --- /dev/null +++ b/Sources/PdfClient/Views/RoomTable.swift @@ -0,0 +1,50 @@ +import Elementary +import ManualDCore + +struct RoomsTable: HTML, Sendable { + let rooms: [Room] + let projectSHR: Double + + var body: some HTML { + 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 {} + } + } + } + } +} diff --git a/Sources/PdfClient/Views/TrunkTable.swift b/Sources/PdfClient/Views/TrunkTable.swift new file mode 100644 index 0000000..40351fb --- /dev/null +++ b/Sources/PdfClient/Views/TrunkTable.swift @@ -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 { + 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() ?? "" } + } + } + } + } + } +} diff --git a/Sources/ProjectClient/Interface.swift b/Sources/ProjectClient/Interface.swift index 4de8cbe..47fd81a 100644 --- a/Sources/ProjectClient/Interface.swift +++ b/Sources/ProjectClient/Interface.swift @@ -1,7 +1,9 @@ import Dependencies import DependenciesMacros +import Elementary import ManualDClient import ManualDCore +import Vapor extension DependencyValues { public var projectClient: ProjectClient { @@ -26,6 +28,7 @@ public struct ProjectClient: Sendable { @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 { diff --git a/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift b/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift index 1380283..5e81d2a 100644 --- a/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift +++ b/Sources/ProjectClient/Internal/DatabaseClient+calculateDuctSizes.swift @@ -5,38 +5,113 @@ 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 { + ) async throws -> (DuctSizes, DuctSizeSharedRequest, [Room]) { @Dependency(\.manualD) var manualD - return try await manualD.calculateDuctSizes( - rooms: rooms.fetch(projectID), - trunks: trunkSizes.fetch(projectID), - sharedRequest: sharedDuctRequest(projectID) + 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( - projectID: Project.ID - ) async throws -> [DuctSizes.RoomContainer] { + details: Project.Detail + ) async throws -> ([DuctSizes.RoomContainer], DuctSizeSharedRequest) { @Dependency(\.manualD) var manualD - return try await manualD.calculateRoomSizes( - rooms: rooms.fetch(projectID), - sharedRequest: sharedDuctRequest(projectID) + 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( - projectID: Project.ID - ) async throws -> [DuctSizes.TrunkContainer] { + details: Project.Detail + ) async throws -> ([DuctSizes.TrunkContainer], DuctSizeSharedRequest) { @Dependency(\.manualD) var manualD - return try await manualD.calculateTrunkSizes( - rooms: rooms.fetch(projectID), - trunks: trunkSizes.fetch(projectID), - sharedRequest: sharedDuctRequest(projectID) + 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 ) } @@ -90,25 +165,36 @@ extension DatabaseClient { } func designFrictionRate( - projectID: Project.ID - ) async throws -> DesignFrictionRateResponse? { - guard let equipmentInfo = try await equipment.fetch(projectID) else { - return nil - } + componentLosses: [ComponentPressureLoss], + equipmentInfo: EquipmentInfo, + equivalentLengths: EffectiveLength.MaxContainer + ) -> DesignFrictionRateResponse? { + guard let tel = equivalentLengths.total, + componentLosses.count > 0 + 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 availableStaticPressure = equipmentInfo.staticPressure - componentLosses.total return .init( designFrictionRate: (availableStaticPressure * 100) / tel, equipmentInfo: equipmentInfo, telMaxContainer: equivalentLengths ) + + } + + func designFrictionRate( + projectID: Project.ID + ) async throws -> DesignFrictionRateResponse? { + + guard let equipmentInfo = try await equipment.fetch(projectID) else { + return nil + } + + return try await designFrictionRate( + componentLosses: componentLoss.fetch(projectID), + equipmentInfo: equipmentInfo, + equivalentLengths: effectiveLength.fetchMax(projectID) + ) } } diff --git a/Sources/ProjectClient/Internal/ManualDClient+frictionRate.swift b/Sources/ProjectClient/Internal/ManualDClient+frictionRate.swift new file mode 100644 index 0000000..3447c27 --- /dev/null +++ b/Sources/ProjectClient/Internal/ManualDClient+frictionRate.swift @@ -0,0 +1,55 @@ +import DatabaseClient +import Dependencies +import ManualDClient +import ManualDCore + +extension ManualDClient { + + func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse { + + let maxContainer = details.maxContainer + guard let totalEquivalentLength = maxContainer.total else { + return .init(componentLosses: details.componentLosses, equivalentLengths: maxContainer) + } + + return try await .init( + componentLosses: details.componentLosses, + equivalentLengths: maxContainer, + frictionRate: frictionRate( + .init( + externalStaticPressure: details.equipmentInfo.staticPressure, + componentPressureLosses: details.componentLosses, + totalEffectiveLength: Int(totalEquivalentLength) + ) + ) + ) + } + + func frictionRate(projectID: Project.ID) async throws -> ProjectClient.FrictionRateResponse { + @Dependency(\.database) var database + + let componentLosses = try await database.componentLoss.fetch(projectID) + let lengths = try await database.effectiveLength.fetchMax(projectID) + + let equipmentInfo = try await database.equipment.fetch(projectID) + guard let staticPressure = equipmentInfo?.staticPressure else { + return .init(componentLosses: componentLosses, equivalentLengths: lengths) + } + + guard let totalEquivalentLength = lengths.total else { + return .init(componentLosses: componentLosses, equivalentLengths: lengths) + } + + return try await .init( + componentLosses: componentLosses, + equivalentLengths: lengths, + frictionRate: frictionRate( + .init( + externalStaticPressure: staticPressure, + componentPressureLosses: database.componentLoss.fetch(projectID), + totalEffectiveLength: Int(totalEquivalentLength) + ) + ) + ) + } +} diff --git a/Sources/ProjectClient/Internal/ProjectDetail+maxContainer.swift b/Sources/ProjectClient/Internal/ProjectDetail+maxContainer.swift new file mode 100644 index 0000000..ef0e8a1 --- /dev/null +++ b/Sources/ProjectClient/Internal/ProjectDetail+maxContainer.swift @@ -0,0 +1,14 @@ +import ManualDCore + +extension Project.Detail { + var maxContainer: EffectiveLength.MaxContainer { + .init( + supply: equivalentLengths.filter({ $0.type == .supply }) + .sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength }) + .first, + return: equivalentLengths.filter({ $0.type == .return }) + .sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength }) + .first + ) + } +} diff --git a/Sources/ProjectClient/Live.swift b/Sources/ProjectClient/Live.swift index 1c69e52..9d3b29c 100644 --- a/Sources/ProjectClient/Live.swift +++ b/Sources/ProjectClient/Live.swift @@ -1,24 +1,28 @@ 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) + try await database.calculateDuctSizes(projectID: projectID).0 }, calculateRoomDuctSizes: { projectID in - try await database.calculateRoomDuctSizes(projectID: projectID) + try await database.calculateRoomDuctSizes(projectID: projectID).0 }, calculateTrunkDuctSizes: { projectID in - try await database.calculateTrunkDuctSizes(projectID: projectID) + try await database.calculateTrunkDuctSizes(projectID: projectID).0 }, createProject: { userID, request in let project = try await database.projects.create(userID, request) @@ -31,32 +35,76 @@ extension ProjectClient: DependencyKey { ) }, frictionRate: { projectID in - - let componentLosses = try await database.componentLoss.fetch(projectID) - let lengths = try await database.effectiveLength.fetchMax(projectID) - - let equipmentInfo = try await database.equipment.fetch(projectID) - guard let staticPressure = equipmentInfo?.staticPressure else { - return .init(componentLosses: componentLosses, equivalentLengths: lengths) - } - - guard let totalEquivalentLength = lengths.total else { - return .init(componentLosses: componentLosses, equivalentLengths: lengths) - } - - return try await .init( - componentLosses: componentLosses, - equivalentLengths: lengths, - frictionRate: manualD.frictionRate( - .init( - externalStaticPressure: staticPressure, - componentPressureLosses: database.componentLoss.fetch(projectID), - totalEffectiveLength: Int(totalEquivalentLength) - ) - ) + 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 + ) + } +} diff --git a/Sources/Styleguide/Date.swift b/Sources/Styleguide/Date.swift index e679e4e..bf10d51 100644 --- a/Sources/Styleguide/Date.swift +++ b/Sources/Styleguide/Date.swift @@ -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 } diff --git a/Sources/Styleguide/HTMXExtensions.swift b/Sources/Styleguide/HTMXExtensions.swift index a2496e0..2ba2e84 100644 --- a/Sources/Styleguide/HTMXExtensions.swift +++ b/Sources/Styleguide/HTMXExtensions.swift @@ -32,6 +32,6 @@ extension HTMLAttribute.hx { extension HTMLAttribute.hx { @Sendable public static func indicator() -> HTMLAttribute { - indicator(".hx-indicator") + indicator(".htmx-indicator") } } diff --git a/Sources/Styleguide/Number.swift b/Sources/Styleguide/Number.swift index fe261dc..ecc466a 100644 --- a/Sources/Styleguide/Number.swift +++ b/Sources/Styleguide/Number.swift @@ -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 { - span { formatter.string(for: value) ?? "N/A" } + span { value.string(digits: fractionDigits) } } } diff --git a/Sources/Styleguide/ResultView.swift b/Sources/Styleguide/ResultView.swift index 227c4aa..ac4ab0a 100644 --- a/Sources/Styleguide/ResultView.swift +++ b/Sources/Styleguide/ResultView.swift @@ -1,89 +1,76 @@ import Elementary import Foundation -public struct ResultView< - V: Sendable, - E: Error, - ValueView: HTML, - ErrorView: HTML ->: HTML { +public struct ResultView: HTML where ValueView: HTML, ErrorView: HTML { - let onSuccess: @Sendable (V) -> ValueView - let onError: @Sendable (E) -> ErrorView - let result: Result + let result: Result + let errorView: @Sendable (any Error) -> ErrorView public init( - result: Result, - @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, - @HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView - ) where ErrorView == Styleguide.ErrorView { - 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( + 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 { + 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, 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: 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 { 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)" } } } - } diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 13142e2..f9363fc 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -3,6 +3,7 @@ import Dependencies import Elementary import Foundation import ManualDCore +import PdfClient import ProjectClient import Styleguide @@ -12,22 +13,27 @@ extension ViewController.Request { @Dependency(\.database) var database @Dependency(\.projectClient) var projectClient + @Dependency(\.pdfClient) var pdfClient switch route { case .test: // 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) - // ) - } onSuccess: { - TestPage() - // TestPage(trunks: result.trunks, rooms: result.rooms) - } - } + // 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): @@ -187,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) } diff --git a/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift index 9963508..dc6b09a 100644 --- a/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift +++ b/Sources/ViewController/Views/DuctSizing/DuctSizingView.swift @@ -23,6 +23,23 @@ struct DuctSizingView: HTML, Sendable { .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 ductSizes.rooms.count != 0 { diff --git a/Sources/ViewController/Views/DuctSizing/TrunksTable.swift b/Sources/ViewController/Views/DuctSizing/TrunksTable.swift index ced8f52..3bf4e15 100644 --- a/Sources/ViewController/Views/DuctSizing/TrunksTable.swift +++ b/Sources/ViewController/Views/DuctSizing/TrunksTable.swift @@ -134,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() } } diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift index a808f94..933a1ff 100644 --- a/Sources/ViewController/Views/MainPage.swift +++ b/Sources/ViewController/Views/MainPage.swift @@ -50,8 +50,10 @@ public struct MainPage: 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"), diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift index a0ed5b2..acc8085 100644 --- a/Sources/ViewController/Views/Rooms/RoomsView.swift +++ b/Sources/ViewController/Views/Rooms/RoomsView.swift @@ -48,19 +48,19 @@ struct RoomsView: HTML, Sendable { 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")) } } @@ -238,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 - } - } -} diff --git a/Tests/ManualDClientTests/ManualDClientTests.swift b/Tests/ManualDClientTests/ManualDClientTests.swift index b2d02d4..ce5d00f 100644 --- a/Tests/ManualDClientTests/ManualDClientTests.swift +++ b/Tests/ManualDClientTests/ManualDClientTests.swift @@ -33,31 +33,31 @@ struct ManualDClientTests { #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 { diff --git a/Tests/PdfClientTests/PdfClientTests.swift b/Tests/PdfClientTests/PdfClientTests.swift new file mode 100644 index 0000000..7fcd0e0 --- /dev/null +++ b/Tests/PdfClientTests/PdfClientTests.swift @@ -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) + } + + } +} diff --git a/Tests/PdfClientTests/__Snapshots__/PdfClientTests/html.1.html b/Tests/PdfClientTests/__Snapshots__/PdfClientTests/html.1.html new file mode 100644 index 0000000..5a18633 --- /dev/null +++ b/Tests/PdfClientTests/__Snapshots__/PdfClientTests/html.1.html @@ -0,0 +1,583 @@ + + + + Duct Calc + + + +
+

Project

+
+ + + + + + + + + + + +
NameTesty McTestface
Address +

+ 1234 Sesame Street +
+ Monroe, OH 55555 +

+
+
+
+
+
+

Equipment

+

Friction Rate

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
EquipmentValue
Static Pressure0.5
Heating CFM900
Cooling CFM1,000
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Friction RateValue
evaporator-coil0.2
filter0.1
supply-outlet0.03
return-grille0.03
balancing-damper0.03
+
+
+
+
+
+

Duct Sizes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameDsn CFMRound SizeVelocityFinal SizeFlex SizeHeightWidth
Bed-192748988
Entry88748988
Entry88748988
Family Room92748988
Family Room92748988
Family Room92748988
Kitchen95748988
Kitchen95748988
Living Room127748988
Living Room127748988
Master87748988
Master87748988
+
+
+

Supply Trunk / Run Outs

+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameDsn CFMRound SizeVelocityFinal SizeFlex SizeHeightWidth
1,000189872020
+
+
+

Return Trunk / Run Outs

+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameDsn CFMRound SizeVelocityFinal SizeFlex SizeHeightWidth
1,000189872020
+
+
+

Total Equivalent Lengths

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeStraight LengthsGroupsTotal
Supply - 1supply +
    +
  • 10
  • +
  • 25
  • +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameLengthQuantityTotal
1-a20120
2-b30130
3-a10110
12-a10110
+
105
Return - 1return +
    +
  • 10
  • +
  • 20
  • +
  • 5
  • +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameLengthQuantityTotal
5-a10110
6-a15115
7-a20120
+
80
+
+
+

Register Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameHeating BTUCooling BTUHeating CFMCooling CFMDesign CFM
Bed-13,9132,472839292
Entry4,1421,458885488
Entry4,1421,458885488
Family Room3,2622,482699292
Family Room3,2622,482699292
Family Room3,2622,482699292
Kitchen2,2592,548489595
Kitchen2,2592,548489595
Living Room3,7763,41480127127
Living Room3,7763,41480127127
Master4,1011,038873987
Master4,1011,038873987
+
+
+

Room Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameHeating BTUCooling Total BTUCooling Sensible BTURegister Count
Bed-13,9132,4722,0521
Entry8,2842,9162,4202
Family Room9,7857,4466,1803
Kitchen4,5185,0964,2302
Living Room7,5536,8295,6682
Master8,2022,0761,7232
Totals42,25526,83522,273
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/ViewControllerTests.swift b/Tests/ViewControllerTests/ViewControllerTests.swift new file mode 100644 index 0000000..bc29891 --- /dev/null +++ b/Tests/ViewControllerTests/ViewControllerTests.swift @@ -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 = EffectiveLength.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.effectiveLength.fetch = { _ in tels } + $0.database.effectiveLength.fetchMax = { _ in + .init(supply: tels.first, return: tels.last) + } + $0.database.componentLoss.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( + 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.userProfile.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 {} diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/login.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/login.1.html new file mode 100644 index 0000000..a6dc2a8 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/login.1.html @@ -0,0 +1,81 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.1.html new file mode 100644 index 0000000..ca19d9c --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.1.html @@ -0,0 +1,253 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+

Project

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Name +
Testy McTestface
+
Street Address +
1234 Sesame Street
+
City +
Monroe
+
State +
OH
+
Zip +
55555
+
+ + + +
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html new file mode 100644 index 0000000..12a954d --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.2.html @@ -0,0 +1,595 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+
+
+

Room Loads

+
+
+
+ +
+
+
+ Heating Total +
42,255
+
+
+ Cooling Total +
26,835
+
+
+ Cooling Sensible +
22,273
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name +
Heating Load
+
+
Cooling Total
+
+
Cooling Sensible
+
+
Register Count
+
+
+
+ +
+
+
Bed-1 +
3,913
+
+
2,472
+
+
2,052
+
+
1
+
+
+
+
+ +
+
+ +
+
+
+ + + +
Entry +
8,284
+
+
2,916
+
+
2,420
+
+
2
+
+
+
+
+ +
+
+ +
+
+
+ + + +
Family Room +
9,785
+
+
7,446
+
+
6,180
+
+
3
+
+
+
+
+ +
+
+ +
+
+
+ + + +
Kitchen +
4,518
+
+
5,096
+
+
4,230
+
+
2
+
+
+
+
+ +
+
+ +
+
+
+ + + +
Living Room +
7,553
+
+
6,829
+
+
5,668
+
+
2
+
+
+
+
+ +
+
+ +
+
+
+ + + +
Master +
8,202
+
+
2,076
+
+
1,723
+
+
2
+
+
+
+
+ +
+
+ +
+
+
+ + + +
+ + + +
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.3.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.3.html new file mode 100644 index 0000000..0513085 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.3.html @@ -0,0 +1,238 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+

Equipment Details

+
+ +
+
+ + + + + + + + + + + + + + + +
Static Pressure +
0.5
+
Heating CFM +
900
+
Cooling CFM +
1,000
+
+ + + +
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.4.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.4.html new file mode 100644 index 0000000..443518e --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.4.html @@ -0,0 +1,368 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+

Equivalent Lengths

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TypeNameStraight Lengths +
+
Groups
+
Group
+
T.E.L.
+
Quantity
+
+
+
T.E.L.
+
+
supply
+
Supply - 1 +
1025
+
+
+ 1-a +
20
+
1
+ 2-b +
30
+
1
+ 3-a +
10
+
1
+ 12-a +
10
+
1
+
+
+
+
105
+
+
+
+ +
+
+ +
+
+
+
+ + + +
+
return
+
Return - 1 +
10205
+
+
+ 5-a +
10
+
1
+ 6-a +
15
+
1
+ 7-a +
20
+
1
+
+
+
+
80
+
+
+
+ +
+
+ +
+
+
+
+ + + +
+
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.5.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.5.html new file mode 100644 index 0000000..4bf598b --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.5.html @@ -0,0 +1,444 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+
+

Friction Rate

+
+
+ Friction Rate Design Value +
0.06
+
+
+ Available Static Pressure +
0.11
+
+
+ Component Pressure Losses +
0.39
+
+
+
+ + + + +
+
+
+
+
+

Component Pressure Losses

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameValue
evaporator-coil0.2 +
+
+ +
+
+ +
+
+ + + +
filter0.1 +
+
+ +
+
+ +
+
+ + + +
supply-outlet0.03 +
+
+ +
+
+ +
+
+ + + +
return-grille0.03 +
+
+ +
+
+ +
+
+ + + +
balancing-damper0.03 +
+
+ +
+
+ +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.6.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.6.html new file mode 100644 index 0000000..0013fdf --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectDetail.6.html @@ -0,0 +1,1563 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+ +
+
+
+
+

Duct Sizes

+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameBTUCFMVelocitySize
Bed-1 +
Heating3,913Cooling2,472
+
+
+ Design +
+
92
+
+ Heating +
83
+ Cooling +
92
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Entry +
Heating4,142Cooling1,458
+
+
+ Design +
+
88
+
+ Heating +
88
+ Cooling +
54
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Entry +
Heating4,142Cooling1,458
+
+
+ Design +
+
88
+
+ Heating +
88
+ Cooling +
54
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Family Room +
Heating3,262Cooling2,482
+
+
+ Design +
+
92
+
+ Heating +
69
+ Cooling +
92
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Family Room +
Heating3,262Cooling2,482
+
+
+ Design +
+
92
+
+ Heating +
69
+ Cooling +
92
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Family Room +
Heating3,262Cooling2,482
+
+
+ Design +
+
92
+
+ Heating +
69
+ Cooling +
92
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Kitchen +
Heating2,259Cooling2,548
+
+
+ Design +
+
95
+
+ Heating +
48
+ Cooling +
95
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Kitchen +
Heating2,259Cooling2,548
+
+
+ Design +
+
95
+
+ Heating +
48
+ Cooling +
95
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Living Room +
Heating3,776Cooling3,414
+
+
+ Design +
+
127
+
+ Heating +
80
+ Cooling +
127
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Living Room +
Heating3,776Cooling3,414
+
+
+ Design +
+
127
+
+ Heating +
80
+ Cooling +
127
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Master +
Heating4,101Cooling1,038
+
+
+ Design +
+
87
+
+ Heating +
87
+ Cooling +
39
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
Master +
Heating4,101Cooling1,038
+
+
+ Design +
+
87
+
+ Heating +
87
+ Cooling +
39
+
+
489 +
+
Calculated
+
+
7
+
+
+
Final
+
+
8
+
+
+
Flex
+
+
8
+
+
+
Rectangular
+
+
+
+
+ +
+
+
+ + + +
+
+
+

Trunk / Runout Sizes

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name / TypeAssociated SuppliesDsn CFMVelocitySize
+
+
supply
+
+
+
+
Bed-1
+
Entry
+
Entry
+
Family Room
+
Family Room
+
Family Room
+
Kitchen
+
Kitchen
+
Living Room
+
Living Room
+
Master
+
Master
+
+
1,000987 +
+
Calculated
+
+
18
+
+
+
Final
+
+
20
+
+
+
Flex
+
+
20
+
+
+
Rectangular
+
+
+
+ +
+
+
+ + + +
+
+
return
+
+
+
+
Bed-1
+
Entry
+
Entry
+
Family Room
+
Family Room
+
Family Room
+
Kitchen
+
Kitchen
+
Living Room
+
Living Room
+
Master
+
Master
+
+
1,000987 +
+
Calculated
+
+
18
+
+
+
Final
+
+
20
+
+
+
Flex
+
+
20
+
+
+
Rectangular
+
+
+
+ +
+
+
+ + + +
+ + + +
+
+
+
+ +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectIndex.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectIndex.1.html new file mode 100644 index 0000000..e1f8f9f --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/projectIndex.1.html @@ -0,0 +1,113 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

Projects

+
+ +
+
+ + + + + + + + + + + + + + + + + +
DateNameAddress
02/13/2009Testy McTestface1234 Sesame Street +
+
+
+ +
+
+
+
+
+
+ + + +
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/signup.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/signup.1.html new file mode 100644 index 0000000..a6dc2a8 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/signup.1.html @@ -0,0 +1,81 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+ + \ No newline at end of file diff --git a/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userProfile.1.html b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userProfile.1.html new file mode 100644 index 0000000..62a38d4 --- /dev/null +++ b/Tests/ViewControllerTests/__Snapshots__/ViewControllerTests/userProfile.1.html @@ -0,0 +1,161 @@ + + + + Duct Calc + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

Account

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTesty McTestface
CompanyAcme Co.
Street Address1234 Sesame St
CityMonroe
StateOH
Zip Code55555
Theme
+ + + +
+
+
+
+ +
+
+ + \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 071df30..c752c03 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 482bb5a..3953ed6 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -17,6 +17,9 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ npm \ build-essential \ curl \ + wkhtmltopdf \ + pandoc \ + weasyprint \ && rm -r /var/lib/apt/lists/* # Set up a build area