Merge branch 'feat-pdf'
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
74
Sources/EnvClient/Interface.swift
Normal file
74
Sources/EnvClient/Interface.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
extension DependencyValues {
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public var env: @Sendable () throws -> EnvVars {
|
||||
get { self[EnvClient.self].env }
|
||||
set { self[EnvClient.self].env = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
struct EnvClient: Sendable {
|
||||
public var env: @Sendable () throws -> EnvVars
|
||||
}
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public struct EnvVars: Codable, Equatable, Sendable {
|
||||
|
||||
/// The path to the pandoc executable on the system, used to generate pdf's.
|
||||
public let pandocPath: String
|
||||
|
||||
/// The pdf engine to use with pandoc when creating pdf's.
|
||||
public let pdfEngine: String
|
||||
|
||||
public init(
|
||||
pandocPath: String = "/usr/bin/pandoc",
|
||||
pdfEngine: String = "weasyprint"
|
||||
) {
|
||||
self.pandocPath = pandocPath
|
||||
self.pdfEngine = pdfEngine
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pandocPath = "PANDOC_PATH"
|
||||
case pdfEngine = "PDF_ENGINE"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension EnvClient: DependencyKey {
|
||||
static let testValue = Self()
|
||||
|
||||
static let liveValue = Self(env: {
|
||||
// Convert default values into a dictionary.
|
||||
let defaults =
|
||||
(try? encoder.encode(EnvVars()))
|
||||
.flatMap { try? decoder.decode([String: String].self, from: $0) }
|
||||
?? [:]
|
||||
|
||||
// Merge the default values with values found in process environment.
|
||||
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
|
||||
|
||||
return (try? JSONSerialization.data(withJSONObject: assigned))
|
||||
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
|
||||
?? .init()
|
||||
})
|
||||
}
|
||||
|
||||
private let encoder: JSONEncoder = {
|
||||
JSONEncoder()
|
||||
}()
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
JSONDecoder()
|
||||
}()
|
||||
40
Sources/FileClient/Interface.swift
Normal file
40
Sources/FileClient/Interface.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
import Vapor
|
||||
|
||||
extension DependencyValues {
|
||||
public var fileClient: FileClient {
|
||||
get { self[FileClient.self] }
|
||||
set { self[FileClient.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct FileClient: Sendable {
|
||||
public typealias OnCompleteHandler = @Sendable () async throws -> Void
|
||||
|
||||
public var writeFile: @Sendable (String, String) async throws -> Void
|
||||
public var removeFile: @Sendable (String) async throws -> Void
|
||||
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response
|
||||
}
|
||||
|
||||
extension FileClient: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(fileIO: FileIO) -> Self {
|
||||
.init(
|
||||
writeFile: { contents, path in
|
||||
try await fileIO.writeFile(ByteBuffer(string: contents), at: path)
|
||||
},
|
||||
removeFile: { path in
|
||||
try FileManager.default.removeItem(atPath: path)
|
||||
},
|
||||
streamFile: { path, onComplete in
|
||||
try await fileIO.asyncStreamFile(at: path) { _ in
|
||||
try await onComplete()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
22
Sources/HTMLSnapshotTesting/Snapshotting.swift
Normal file
22
Sources/HTMLSnapshotTesting/Snapshotting.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Elementary
|
||||
import SnapshotTesting
|
||||
|
||||
extension Snapshotting where Value == (any HTML), Format == String {
|
||||
public static var html: Snapshotting {
|
||||
var snapshotting = SimplySnapshotting.lines
|
||||
.pullback { (html: any HTML) in html.renderFormatted() }
|
||||
|
||||
snapshotting.pathExtension = "html"
|
||||
return snapshotting
|
||||
}
|
||||
}
|
||||
|
||||
extension Snapshotting where Value == String, Format == String {
|
||||
public static var html: Snapshotting {
|
||||
var snapshotting = SimplySnapshotting.lines
|
||||
.pullback { $0 }
|
||||
|
||||
snapshotting.pathExtension = "html"
|
||||
return snapshotting
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -130,5 +130,127 @@ extension DuctSizes {
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizes.SizeContainer, T>) -> T {
|
||||
ductSize[keyPath: keyPath]
|
||||
}
|
||||
|
||||
public func registerIDS(rooms: [RoomContainer]) -> [String] {
|
||||
trunk.rooms.reduce(into: []) { array, room in
|
||||
array = room.registers.reduce(into: array) { array, register in
|
||||
if let room =
|
||||
rooms
|
||||
.first(where: { $0.roomID == room.id && $0.roomRegister == register })
|
||||
{
|
||||
array.append(room.roomName)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension DuctSizes {
|
||||
public static func mock(
|
||||
equipmentInfo: EquipmentInfo,
|
||||
rooms: [Room],
|
||||
trunks: [TrunkSize]
|
||||
) -> Self {
|
||||
|
||||
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||
let totalCoolingLoad = rooms.totalCoolingLoad
|
||||
|
||||
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
|
||||
array += RoomContainer.mock(
|
||||
room: room,
|
||||
totalHeatingLoad: totalHeatingLoad,
|
||||
totalCoolingLoad: totalCoolingLoad,
|
||||
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
|
||||
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
|
||||
)
|
||||
}
|
||||
|
||||
return .init(
|
||||
rooms: roomContainers,
|
||||
trunks: TrunkContainer.mock(
|
||||
trunks: trunks,
|
||||
totalHeatingLoad: totalHeatingLoad,
|
||||
totalCoolingLoad: totalCoolingLoad,
|
||||
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
|
||||
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DuctSizes.RoomContainer {
|
||||
public static func mock(
|
||||
room: Room,
|
||||
totalHeatingLoad: Double,
|
||||
totalCoolingLoad: Double,
|
||||
totalHeatingCFM: Double,
|
||||
totalCoolingCFM: Double
|
||||
) -> [Self] {
|
||||
var retval = [DuctSizes.RoomContainer]()
|
||||
let heatingLoad = room.heatingLoad / Double(room.registerCount)
|
||||
let heatingFraction = heatingLoad / totalHeatingLoad
|
||||
let heatingCFM = totalHeatingCFM * heatingFraction
|
||||
// Not really accurate, but works for mocks.
|
||||
let coolingLoad = room.coolingTotal / Double(room.registerCount)
|
||||
let coolingFraction = coolingLoad / totalCoolingLoad
|
||||
let coolingCFM = totalCoolingCFM * coolingFraction
|
||||
|
||||
for n in 1...room.registerCount {
|
||||
|
||||
retval.append(
|
||||
.init(
|
||||
roomID: room.id,
|
||||
roomName: room.name,
|
||||
roomRegister: n,
|
||||
heatingLoad: heatingLoad,
|
||||
coolingLoad: coolingLoad,
|
||||
heatingCFM: heatingCFM,
|
||||
coolingCFM: coolingCFM,
|
||||
ductSize: .init(
|
||||
rectangularID: nil,
|
||||
designCFM: .init(heating: heatingCFM, cooling: coolingCFM),
|
||||
roundSize: 7,
|
||||
finalSize: 8,
|
||||
velocity: 489,
|
||||
flexSize: 8,
|
||||
height: nil,
|
||||
width: nil
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return retval
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DuctSizes.TrunkContainer {
|
||||
|
||||
public static func mock(
|
||||
trunks: [TrunkSize],
|
||||
totalHeatingLoad: Double,
|
||||
totalCoolingLoad: Double,
|
||||
totalHeatingCFM: Double,
|
||||
totalCoolingCFM: Double
|
||||
) -> [Self] {
|
||||
trunks.reduce(into: []) { array, trunk in
|
||||
array.append(
|
||||
.init(
|
||||
trunk: trunk,
|
||||
ductSize: .init(
|
||||
designCFM: .init(heating: totalHeatingCFM, cooling: totalCoolingCFM),
|
||||
roundSize: 18,
|
||||
finalSize: 20,
|
||||
velocity: 987,
|
||||
flexSize: 20
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
24
Sources/ManualDCore/Numbers+string.swift
Normal file
24
Sources/ManualDCore/Numbers+string.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
extension Double {
|
||||
|
||||
public func string(digits: Int = 2) -> String {
|
||||
numberString(self, digits: digits)
|
||||
}
|
||||
}
|
||||
|
||||
extension Int {
|
||||
|
||||
public func string() -> String {
|
||||
numberString(Double(self), digits: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func numberString(_ value: Double, digits: Int = 2) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.maximumFractionDigits = digits
|
||||
formatter.groupingSize = 3
|
||||
formatter.groupingSeparator = ","
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(for: value)!
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
107
Sources/PdfClient/Request+html.swift
Normal file
107
Sources/PdfClient/Request+html.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
extension PdfClient.Request {
|
||||
|
||||
func toHTML() -> (some HTML & Sendable) {
|
||||
PdfDocument(request: self)
|
||||
}
|
||||
}
|
||||
|
||||
struct PdfDocument: HTMLDocument {
|
||||
|
||||
let title = "Duct Calc"
|
||||
let lang = "en"
|
||||
let request: PdfClient.Request
|
||||
|
||||
var head: some HTML {
|
||||
link(.rel(.stylesheet), .href("/css/pdf.css"))
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
div {
|
||||
// h1(.class("headline")) { "Duct Calc" }
|
||||
|
||||
h2 { "Project" }
|
||||
|
||||
div(.class("flex")) {
|
||||
ProjectTable(project: request.project)
|
||||
// HACK:
|
||||
table {}
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
div(.class("flex")) {
|
||||
h2 { "Equipment" }
|
||||
h2 { "Friction Rate" }
|
||||
}
|
||||
div(.class("flex")) {
|
||||
div(.class("container")) {
|
||||
div(.class("table-container")) {
|
||||
EquipmentTable(title: "Equipment", equipmentInfo: request.equipmentInfo)
|
||||
}
|
||||
div(.class("table-container")) {
|
||||
FrictionRateTable(
|
||||
title: "Friction Rate",
|
||||
componentLosses: request.componentLosses,
|
||||
frictionRate: request.frictionRate,
|
||||
totalEquivalentLength: request.totalEquivalentLength,
|
||||
displayTotals: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let error = request.frictionRate.error {
|
||||
div(.class("section")) {
|
||||
p(.class("error")) {
|
||||
error.reason
|
||||
for resolution in error.resolutions {
|
||||
br()
|
||||
" * \(resolution)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("section")) {
|
||||
h2 { "Duct Sizes" }
|
||||
DuctSizesTable(rooms: request.ductSizes.rooms)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Supply Trunk / Run Outs" }
|
||||
TrunkTable(sizes: request.ductSizes, type: .supply)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Return Trunk / Run Outs" }
|
||||
TrunkTable(sizes: request.ductSizes, type: .return)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Total Equivalent Lengths" }
|
||||
EffectiveLengthsTable(effectiveLengths: [
|
||||
request.maxSupplyTEL, request.maxReturnTEL,
|
||||
])
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Register Detail" }
|
||||
RegisterDetailTable(rooms: request.ductSizes.rooms)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
|
||||
div(.class("section")) {
|
||||
h2 { "Room Detail" }
|
||||
RoomsTable(rooms: request.rooms, projectSHR: request.projectSHR)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) |
|
||||
"""
|
||||
}
|
||||
}
|
||||
37
Sources/PdfClient/Views/DuctSizeTable.swift
Normal file
37
Sources/PdfClient/Views/DuctSizeTable.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct DuctSizesTable: HTML, Sendable {
|
||||
let rooms: [DuctSizes.RoomContainer]
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { "Name" }
|
||||
th { "Dsn CFM" }
|
||||
th { "Round Size" }
|
||||
th { "Velocity" }
|
||||
th { "Final Size" }
|
||||
th { "Flex Size" }
|
||||
th { "Height" }
|
||||
th { "Width" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in rooms {
|
||||
tr {
|
||||
td { row.roomName }
|
||||
td { row.designCFM.value.string(digits: 0) }
|
||||
td { row.roundSize.string() }
|
||||
td { row.velocity.string() }
|
||||
td { row.flexSize.string() }
|
||||
td { row.finalSize.string() }
|
||||
td { row.ductSize.height?.string() ?? "" }
|
||||
td { row.width?.string() ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Sources/PdfClient/Views/EquipmentTable.swift
Normal file
38
Sources/PdfClient/Views/EquipmentTable.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct EquipmentTable: HTML, Sendable {
|
||||
let title: String?
|
||||
let equipmentInfo: EquipmentInfo
|
||||
|
||||
init(title: String? = nil, equipmentInfo: EquipmentInfo) {
|
||||
self.title = title
|
||||
self.equipmentInfo = equipmentInfo
|
||||
}
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { title ?? "" }
|
||||
th(.class("justify-end")) { "Value" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
tr {
|
||||
td { "Static Pressure" }
|
||||
td(.class("justify-end")) { equipmentInfo.staticPressure.string() }
|
||||
}
|
||||
tr {
|
||||
td { "Heating CFM" }
|
||||
td(.class("justify-end")) { equipmentInfo.heatingCFM.string() }
|
||||
}
|
||||
tr {
|
||||
td { "Cooling CFM" }
|
||||
td(.class("justify-end")) { equipmentInfo.coolingCFM.string() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
Sources/PdfClient/Views/EquivalentLengthTable.swift
Normal file
68
Sources/PdfClient/Views/EquivalentLengthTable.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct EffectiveLengthsTable: HTML, Sendable {
|
||||
let effectiveLengths: [EffectiveLength]
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { "Name" }
|
||||
th { "Type" }
|
||||
th { "Straight Lengths" }
|
||||
th { "Groups" }
|
||||
th { "Total" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in effectiveLengths {
|
||||
tr {
|
||||
td { row.name }
|
||||
td { row.type.rawValue }
|
||||
td {
|
||||
ul {
|
||||
for length in row.straightLengths {
|
||||
li { length.string() }
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
EffectiveLengthGroupTable(groups: row.groups)
|
||||
.attributes(.class("w-full"))
|
||||
}
|
||||
td { row.totalEquivalentLength.string(digits: 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct EffectiveLengthGroupTable: HTML, Sendable {
|
||||
let groups: [EffectiveLength.Group]
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("effectiveLengthGroupHeader")) {
|
||||
th { "Name" }
|
||||
th { "Length" }
|
||||
th { "Quantity" }
|
||||
th { "Total" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in groups {
|
||||
tr {
|
||||
td { "\(row.group)-\(row.letter)" }
|
||||
td { row.value.string(digits: 0) }
|
||||
td { row.quantity.string() }
|
||||
td { (row.value * Double(row.quantity)).string(digits: 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Sources/PdfClient/Views/FrictionRateTable.swift
Normal file
47
Sources/PdfClient/Views/FrictionRateTable.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct FrictionRateTable: HTML, Sendable {
|
||||
let title: String?
|
||||
let componentLosses: [ComponentPressureLoss]
|
||||
let frictionRate: FrictionRate
|
||||
let totalEquivalentLength: Double
|
||||
let displayTotals: Bool
|
||||
|
||||
var sortedLosses: [ComponentPressureLoss] {
|
||||
componentLosses.sorted { $0.value > $1.value }
|
||||
}
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { title ?? "" }
|
||||
th(.class("justify-end")) { "Value" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in sortedLosses {
|
||||
tr {
|
||||
td { row.name }
|
||||
td(.class("justify-end")) { row.value.string() }
|
||||
}
|
||||
}
|
||||
if displayTotals {
|
||||
tr {
|
||||
td(.class("label justify-end")) { "Available Static Pressure" }
|
||||
td(.class("justify-end")) { frictionRate.availableStaticPressure.string() }
|
||||
}
|
||||
tr {
|
||||
td(.class("label justify-end")) { "Total Equivalent Length" }
|
||||
td(.class("justify-end")) { totalEquivalentLength.string() }
|
||||
}
|
||||
tr {
|
||||
td(.class("label justify-end")) { "Friction Rate Design Value" }
|
||||
td(.class("justify-end")) { frictionRate.value.string() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Sources/PdfClient/Views/ProjectTable.swift
Normal file
33
Sources/PdfClient/Views/ProjectTable.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct ProjectTable: HTML, Sendable {
|
||||
let project: Project
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
tbody {
|
||||
tr {
|
||||
td(.class("label")) { "Name" }
|
||||
td { project.name }
|
||||
}
|
||||
tr {
|
||||
td(.class("label")) { "Address" }
|
||||
td {
|
||||
p {
|
||||
project.streetAddress
|
||||
br()
|
||||
project.cityStateZipString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Project {
|
||||
var cityStateZipString: String {
|
||||
return "\(city), \(state) \(zipCode)"
|
||||
}
|
||||
}
|
||||
33
Sources/PdfClient/Views/RegisterTable.swift
Normal file
33
Sources/PdfClient/Views/RegisterTable.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct RegisterDetailTable: HTML, Sendable {
|
||||
let rooms: [DuctSizes.RoomContainer]
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { "Name" }
|
||||
th { "Heating BTU" }
|
||||
th { "Cooling BTU" }
|
||||
th { "Heating CFM" }
|
||||
th { "Cooling CFM" }
|
||||
th { "Design CFM" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in rooms {
|
||||
tr {
|
||||
td { row.roomName }
|
||||
td { row.heatingLoad.string(digits: 0) }
|
||||
td { row.coolingLoad.string(digits: 0) }
|
||||
td { row.heatingCFM.string(digits: 0) }
|
||||
td { row.coolingCFM.string(digits: 0) }
|
||||
td { row.designCFM.value.string(digits: 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Sources/PdfClient/Views/RoomTable.swift
Normal file
50
Sources/PdfClient/Views/RoomTable.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct RoomsTable: HTML, Sendable {
|
||||
let rooms: [Room]
|
||||
let projectSHR: Double
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead {
|
||||
tr(.class("bg-green")) {
|
||||
th { "Name" }
|
||||
th { "Heating BTU" }
|
||||
th { "Cooling Total BTU" }
|
||||
th { "Cooling Sensible BTU" }
|
||||
th { "Register Count" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for room in rooms {
|
||||
tr {
|
||||
td { room.name }
|
||||
td { room.heatingLoad.string(digits: 0) }
|
||||
td { room.coolingTotal.string(digits: 0) }
|
||||
td {
|
||||
(room.coolingSensible
|
||||
?? (room.coolingTotal * projectSHR)).string(digits: 0)
|
||||
}
|
||||
td { room.registerCount.string() }
|
||||
}
|
||||
}
|
||||
// Totals
|
||||
// tr(.class("table-footer")) {
|
||||
tr {
|
||||
td(.class("label")) { "Totals" }
|
||||
td(.class("heating label")) {
|
||||
rooms.totalHeatingLoad.string(digits: 0)
|
||||
}
|
||||
td(.class("coolingTotal label")) {
|
||||
rooms.totalCoolingLoad.string(digits: 0)
|
||||
}
|
||||
td(.class("coolingSensible label")) {
|
||||
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
|
||||
}
|
||||
td {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Sources/PdfClient/Views/TrunkTable.swift
Normal file
42
Sources/PdfClient/Views/TrunkTable.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Elementary
|
||||
import ManualDCore
|
||||
|
||||
struct TrunkTable: HTML, Sendable {
|
||||
public let sizes: DuctSizes
|
||||
public let type: TrunkSize.TrunkType
|
||||
|
||||
var trunks: [DuctSizes.TrunkContainer] {
|
||||
sizes.trunks.filter { $0.type == type }
|
||||
}
|
||||
|
||||
var body: some HTML<HTMLTag.table> {
|
||||
table {
|
||||
thead(.class("bg-green")) {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "Dsn CFM" }
|
||||
th { "Round Size" }
|
||||
th { "Velocity" }
|
||||
th { "Final Size" }
|
||||
th { "Flex Size" }
|
||||
th { "Height" }
|
||||
th { "Width" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in trunks {
|
||||
tr {
|
||||
td { row.name ?? "" }
|
||||
td { row.designCFM.value.string(digits: 0) }
|
||||
td { row.ductSize.roundSize.string() }
|
||||
td { row.velocity.string() }
|
||||
td { row.finalSize.string() }
|
||||
td { row.flexSize.string() }
|
||||
td { row.ductSize.height?.string() ?? "" }
|
||||
td { row.width?.string() ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
|
||||
extension HTMLAttribute.hx {
|
||||
@Sendable
|
||||
public static func indicator() -> HTMLAttribute {
|
||||
indicator(".hx-indicator")
|
||||
indicator(".htmx-indicator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
public struct Number: HTML, Sendable {
|
||||
let fractionDigits: Int
|
||||
let value: Double
|
||||
|
||||
private var formatter: NumberFormatter {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter
|
||||
}
|
||||
// private var formatter: NumberFormatter {
|
||||
// let formatter = NumberFormatter()
|
||||
// formatter.maximumFractionDigits = fractionDigits
|
||||
// formatter.numberStyle = .decimal
|
||||
// formatter.groupingSize = 3
|
||||
// formatter.groupingSeparator = ","
|
||||
// return formatter
|
||||
// }
|
||||
|
||||
public init(
|
||||
_ value: Double,
|
||||
@@ -27,6 +30,6 @@ public struct Number: HTML, Sendable {
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.span> {
|
||||
span { formatter.string(for: value) ?? "N/A" }
|
||||
span { value.string(digits: fractionDigits) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,89 +1,76 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
|
||||
public struct ResultView<
|
||||
V: Sendable,
|
||||
E: Error,
|
||||
ValueView: HTML,
|
||||
ErrorView: HTML
|
||||
>: HTML {
|
||||
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
|
||||
|
||||
let onSuccess: @Sendable (V) -> ValueView
|
||||
let onError: @Sendable (E) -> ErrorView
|
||||
let result: Result<V, E>
|
||||
let result: Result<ValueView, any Error>
|
||||
let errorView: @Sendable (any Error) -> ErrorView
|
||||
|
||||
public init(
|
||||
result: Result<V, E>,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView,
|
||||
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView
|
||||
) {
|
||||
self.result = result
|
||||
self.onError = onError
|
||||
self.onSuccess = onSuccess
|
||||
_ content: @escaping @Sendable () async throws -> ValueView,
|
||||
onError: @escaping @Sendable (any Error) -> ErrorView
|
||||
) async {
|
||||
self.result = await Result(catching: content)
|
||||
self.errorView = onError
|
||||
}
|
||||
|
||||
public var body: some HTML {
|
||||
switch result {
|
||||
case .success(let value):
|
||||
onSuccess(value)
|
||||
case .success(let view):
|
||||
view
|
||||
case .failure(let error):
|
||||
onError(error)
|
||||
errorView(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ResultView {
|
||||
extension ResultView where ErrorView == Styleguide.ErrorView {
|
||||
|
||||
public init(
|
||||
result: Result<V, E>,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||
) where ErrorView == Styleguide.ErrorView<E> {
|
||||
self.init(result: result, onSuccess: onSuccess) { error in
|
||||
Styleguide.ErrorView(error: error)
|
||||
}
|
||||
_ content: @escaping @Sendable () async throws -> ValueView
|
||||
) async {
|
||||
await self.init(
|
||||
content,
|
||||
onError: { Styleguide.ErrorView(error: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
public init<V: Sendable>(
|
||||
catching: @escaping @Sendable () async throws -> V,
|
||||
onSuccess content: @escaping @Sendable (V) -> ValueView
|
||||
) async where ValueView: Sendable {
|
||||
await self.init(
|
||||
{
|
||||
try await content(catching())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public init(
|
||||
catching: @escaping @Sendable () async throws(E) -> V,
|
||||
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||
) async where ErrorView == Styleguide.ErrorView<E> {
|
||||
catching: @escaping @Sendable () async throws -> Void
|
||||
) async where ValueView == EmptyHTML {
|
||||
await self.init(
|
||||
result: .init(catching: catching),
|
||||
onSuccess: onSuccess
|
||||
) { error in
|
||||
Styleguide.ErrorView(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
catching: @escaping @Sendable () async throws(E) -> V,
|
||||
) async where ErrorView == Styleguide.ErrorView<E>, V == Void, ValueView == EmptyHTML {
|
||||
await self.init(
|
||||
result: .init(catching: catching),
|
||||
catching: catching,
|
||||
onSuccess: { EmptyHTML() }
|
||||
) { error in
|
||||
Styleguide.ErrorView(error: error)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {}
|
||||
extension ResultView: Sendable where ValueView: Sendable, ErrorView: Sendable {}
|
||||
|
||||
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable {
|
||||
public struct ErrorView: HTML, Sendable {
|
||||
let error: any Error
|
||||
|
||||
let error: E
|
||||
|
||||
public init(error: E) {
|
||||
public init(error: any Error) {
|
||||
self.error = error
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.div> {
|
||||
div {
|
||||
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
|
||||
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
|
||||
p {
|
||||
"\(error)"
|
||||
"\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -50,8 +50,10 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
meta(.content("1024"), .name("og:image:height"))
|
||||
meta(.content(keywords), .name(.keywords))
|
||||
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
|
||||
script(.src("/js/htmx-download.js")) {}
|
||||
script(.src("/js/main.js")) {}
|
||||
link(.rel(.stylesheet), .href("/css/output.css"))
|
||||
link(.rel(.stylesheet), .href("/css/htmx.css"))
|
||||
link(
|
||||
.rel(.icon),
|
||||
.href("/images/favicon.ico"),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user