Merge branch 'feat-pdf'

This commit is contained in:
2026-01-29 09:34:41 -05:00
70 changed files with 6674 additions and 287 deletions

View File

@@ -33,6 +33,8 @@ extension SiteRoute.Api.ProjectRoute {
return nil
case .detail(let id, let route):
switch route {
case .index:
return try await database.projects.detail(id)
case .completedSteps:
// FIX:
fatalError()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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())
}
}
// }
}

View File

@@ -0,0 +1,74 @@
import Dependencies
import DependenciesMacros
import Foundation
extension DependencyValues {
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public var env: @Sendable () throws -> EnvVars {
get { self[EnvClient.self].env }
set { self[EnvClient.self].env = newValue }
}
}
@DependencyClient
struct EnvClient: Sendable {
public var env: @Sendable () throws -> EnvVars
}
/// Holds values defined in the process environment that are needed.
///
/// These are generally loaded from a `.env` file, but also have default values,
/// if not found.
public struct EnvVars: Codable, Equatable, Sendable {
/// The path to the pandoc executable on the system, used to generate pdf's.
public let pandocPath: String
/// The pdf engine to use with pandoc when creating pdf's.
public let pdfEngine: String
public init(
pandocPath: String = "/usr/bin/pandoc",
pdfEngine: String = "weasyprint"
) {
self.pandocPath = pandocPath
self.pdfEngine = pdfEngine
}
enum CodingKeys: String, CodingKey {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
}
}
extension EnvClient: DependencyKey {
static let testValue = Self()
static let liveValue = Self(env: {
// Convert default values into a dictionary.
let defaults =
(try? encoder.encode(EnvVars()))
.flatMap { try? decoder.decode([String: String].self, from: $0) }
?? [:]
// Merge the default values with values found in process environment.
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
return (try? JSONSerialization.data(withJSONObject: assigned))
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
?? .init()
})
}
private let encoder: JSONEncoder = {
JSONEncoder()
}()
private let decoder: JSONDecoder = {
JSONDecoder()
}()

View File

@@ -0,0 +1,40 @@
import Dependencies
import DependenciesMacros
import Foundation
import Vapor
extension DependencyValues {
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
@DependencyClient
public struct FileClient: Sendable {
public typealias OnCompleteHandler = @Sendable () async throws -> Void
public var writeFile: @Sendable (String, String) async throws -> Void
public var removeFile: @Sendable (String) async throws -> Void
public var streamFile: @Sendable (String, @escaping OnCompleteHandler) async throws -> Response
}
extension FileClient: TestDependencyKey {
public static let testValue = Self()
public static func live(fileIO: FileIO) -> Self {
.init(
writeFile: { contents, path in
try await fileIO.writeFile(ByteBuffer(string: contents), at: path)
},
removeFile: { path in
try FileManager.default.removeItem(atPath: path)
},
streamFile: { path, onComplete in
try await fileIO.asyncStreamFile(at: path) { _ in
try await onComplete()
}
}
)
}
}

View File

@@ -0,0 +1,22 @@
import Elementary
import SnapshotTesting
extension Snapshotting where Value == (any HTML), Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { (html: any HTML) in html.renderFormatted() }
snapshotting.pathExtension = "html"
return snapshotting
}
}
extension Snapshotting where Value == String, Format == String {
public static var html: Snapshotting {
var snapshotting = SimplySnapshotting.lines
.pullback { $0 }
snapshotting.pathExtension = "html"
return snapshotting
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import Foundation
extension Double {
public func string(digits: Int = 2) -> String {
numberString(self, digits: digits)
}
}
extension Int {
public func string() -> String {
numberString(Double(self), digits: 0)
}
}
private func numberString(_ value: Double, digits: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = digits
formatter.groupingSize = 3
formatter.groupingSeparator = ","
formatter.numberStyle = .decimal
return formatter.string(for: value)!
}

View File

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

View File

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

View File

@@ -88,11 +88,16 @@ extension SiteRoute.Api {
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case index
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.completedSteps)) {
Path {
rootPath

View File

@@ -146,6 +146,7 @@ extension SiteRoute.View.ProjectRoute {
case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute)
case pdf
case rooms(RoomRoute)
static let router = OneOf {
@@ -167,6 +168,10 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.frictionRate)) {
FrictionRateRoute.router
}
Route(.case(Self.pdf)) {
Path { "pdf" }
Method.get
}
Route(.case(Self.rooms)) {
RoomRoute.router
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) |
"""
}
}

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
import Elementary
import ManualDCore
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [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) }
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
import Elementary
import ManualDCore
struct RoomsTable: HTML, Sendable {
let rooms: [Room]
let projectSHR: Double
var body: some HTML<HTMLTag.table> {
table {
thead {
tr(.class("bg-green")) {
th { "Name" }
th { "Heating BTU" }
th { "Cooling Total BTU" }
th { "Cooling Sensible BTU" }
th { "Register Count" }
}
}
tbody {
for room in rooms {
tr {
td { room.name }
td { room.heatingLoad.string(digits: 0) }
td { room.coolingTotal.string(digits: 0) }
td {
(room.coolingSensible
?? (room.coolingTotal * projectSHR)).string(digits: 0)
}
td { room.registerCount.string() }
}
}
// Totals
// tr(.class("table-footer")) {
tr {
td(.class("label")) { "Totals" }
td(.class("heating label")) {
rooms.totalHeatingLoad.string(digits: 0)
}
td(.class("coolingTotal label")) {
rooms.totalCoolingLoad.string(digits: 0)
}
td(.class("coolingSensible label")) {
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
}
td {}
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
import Elementary
import ManualDCore
struct TrunkTable: HTML, Sendable {
public let sizes: DuctSizes
public let type: TrunkSize.TrunkType
var trunks: [DuctSizes.TrunkContainer] {
sizes.trunks.filter { $0.type == type }
}
var body: some HTML<HTMLTag.table> {
table {
thead(.class("bg-green")) {
tr {
th { "Name" }
th { "Dsn CFM" }
th { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Height" }
th { "Width" }
}
}
tbody {
for row in trunks {
tr {
td { row.name ?? "" }
td { row.designCFM.value.string(digits: 0) }
td { row.ductSize.roundSize.string() }
td { row.velocity.string() }
td { row.finalSize.string() }
td { row.flexSize.string() }
td { row.ductSize.height?.string() ?? "" }
td { row.width?.string() ?? "" }
}
}
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
import DatabaseClient
import Dependencies
import ManualDClient
import ManualDCore
extension ManualDClient {
func frictionRate(details: Project.Detail) async throws -> ProjectClient.FrictionRateResponse {
let maxContainer = details.maxContainer
guard let totalEquivalentLength = maxContainer.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)
)
)
)
}
}

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ public struct DateView: HTML, Sendable {
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.dateFormat = "MM/dd/yyyy"
return formatter
}

View File

@@ -32,6 +32,6 @@ extension HTMLAttribute.hx {
extension HTMLAttribute.hx {
@Sendable
public static func indicator() -> HTMLAttribute {
indicator(".hx-indicator")
indicator(".htmx-indicator")
}
}

View File

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

View File

@@ -1,89 +1,76 @@
import Elementary
import Foundation
public struct ResultView<
V: Sendable,
E: Error,
ValueView: HTML,
ErrorView: HTML
>: HTML {
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
let onSuccess: @Sendable (V) -> ValueView
let onError: @Sendable (E) -> ErrorView
let result: Result<V, E>
let result: Result<ValueView, any Error>
let errorView: @Sendable (any Error) -> ErrorView
public init(
result: Result<V, E>,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView,
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView
) {
self.result = result
self.onError = onError
self.onSuccess = onSuccess
_ content: @escaping @Sendable () async throws -> ValueView,
onError: @escaping @Sendable (any Error) -> ErrorView
) async {
self.result = await Result(catching: content)
self.errorView = onError
}
public var body: some HTML {
switch result {
case .success(let value):
onSuccess(value)
case .success(let view):
view
case .failure(let error):
onError(error)
errorView(error)
}
}
}
extension ResultView {
extension ResultView where ErrorView == Styleguide.ErrorView {
public init(
result: Result<V, E>,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
) where ErrorView == Styleguide.ErrorView<E> {
self.init(result: result, onSuccess: onSuccess) { error in
Styleguide.ErrorView(error: error)
}
_ content: @escaping @Sendable () async throws -> ValueView
) async {
await self.init(
content,
onError: { Styleguide.ErrorView(error: $0) }
)
}
public init<V: Sendable>(
catching: @escaping @Sendable () async throws -> V,
onSuccess content: @escaping @Sendable (V) -> ValueView
) async where ValueView: Sendable {
await self.init(
{
try await content(catching())
}
)
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
) async where ErrorView == Styleguide.ErrorView<E> {
catching: @escaping @Sendable () async throws -> Void
) async where ValueView == EmptyHTML {
await self.init(
result: .init(catching: catching),
onSuccess: onSuccess
) { error in
Styleguide.ErrorView(error: error)
}
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
) async where ErrorView == Styleguide.ErrorView<E>, V == Void, ValueView == EmptyHTML {
await self.init(
result: .init(catching: catching),
catching: catching,
onSuccess: { EmptyHTML() }
) { error in
Styleguide.ErrorView(error: error)
}
)
}
}
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {}
extension ResultView: Sendable where ValueView: Sendable, ErrorView: Sendable {}
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable {
public struct ErrorView: HTML, Sendable {
let error: any Error
let error: E
public init(error: E) {
public init(error: any Error) {
self.error = error
}
public var body: some HTML<HTMLTag.div> {
div {
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
p {
"\(error)"
"\(error.localizedDescription)"
}
}
}
}

View File

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

View File

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

View File

@@ -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()
}
}

View File

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

View File

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