feat: Adds result view to better handle errors, integrates it into project view.

This commit is contained in:
2026-01-10 18:27:45 -05:00
parent a356aa2a13
commit 20065ebf10
20 changed files with 342 additions and 152 deletions

View File

@@ -9,4 +9,6 @@ public struct ValidationError: Error {
}
}
public struct NotFoundError: Error {}
public struct NotFoundError: Error {
public init() {}
}

View File

@@ -4,6 +4,7 @@ import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}

View File

@@ -25,7 +25,7 @@ extension ManualDClient: DependencyKey {
throw ManualDError(message: "Total Effective Length should be greater than 0.")
}
let totalComponentLosses = request.componentPressureLosses.totalLosses
let totalComponentLosses = request.componentPressureLosses.totalComponentPressureLoss
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)

View File

@@ -130,12 +130,12 @@ extension ManualDClient {
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: ComponentPressureLosses
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: ComponentPressureLosses,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure

View File

@@ -707,6 +707,9 @@ extension SiteRoute.View.ProjectRoute {
Method.post
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
}
@@ -716,6 +719,7 @@ extension SiteRoute.View.ProjectRoute {
}
public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let register: Int
public let height: Int
}

View File

@@ -65,7 +65,7 @@ public struct EditButton: HTML, Sendable {
}
public var body: some HTML<HTMLTag.button> {
button(.class("btn btn-success btn-circle dark:text-white"), .type(type)) {
button(.class("btn btn-success btn-circle"), .type(type)) {
div(.class("flex")) {
if let title {
span(.class("pe-2")) { title }

View File

@@ -0,0 +1,78 @@
import Elementary
import Foundation
public struct ResultView<
V: Sendable,
E: Error,
ValueView: HTML,
ErrorView: HTML
>: HTML {
let onSuccess: @Sendable (V) -> ValueView
let onError: @Sendable (E) -> ErrorView
let result: Result<V, E>
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
}
public var body: some HTML {
switch result {
case .success(let value):
onSuccess(value)
case .failure(let error):
onError(error)
}
}
}
extension ResultView {
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)
}
}
public init(
catching: @escaping @Sendable () async throws(E) -> V,
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
) async where ErrorView == Styleguide.ErrorView<E> {
await self.init(
result: .init(catching: catching),
onSuccess: onSuccess
) { error in
Styleguide.ErrorView(error: error)
}
}
}
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {}
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable {
let error: E
public init(error: E) {
self.error = error
}
public var body: some HTML<HTMLTag.div> {
div {
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
p {
"\(error)"
}
}
}
}

View File

@@ -3,22 +3,35 @@ import Elementary
public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
let tooltip: String
let position: TooltipPosition
let inner: Inner
public init(
_ tooltip: String,
position: TooltipPosition = .default,
@HTMLBuilder inner: () -> Inner
) {
self.tooltip = tooltip
self.position = position
self.inner = inner()
}
public var body: some HTML<HTMLTag.div> {
div(
.class("tooltip"),
.class("tooltip tooltip-\(position.rawValue)"),
.data("tip", value: tooltip)
) {
inner
}
}
}
public enum TooltipPosition: String, CaseIterable, Sendable {
public static let `default` = Self.left
case bottom
case left
case right
case top
}

View File

@@ -1,5 +1,7 @@
import DatabaseClient
import Dependencies
import Fluent
import ManualDClient
import ManualDCore
import Vapor
@@ -24,6 +26,16 @@ extension DatabaseClient.Projects {
extension DatabaseClient {
func calculateDuctSizes(projectID: Project.ID) async throws -> [DuctSizing.RoomContainer] {
@Dependency(\.manualD) var manualD
return try await manualD.calculate(
rooms: rooms.fetch(projectID),
designFrictionRateResult: designFrictionRate(projectID: projectID),
projectSHR: projects.getSensibleHeatRatio(projectID)
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> (EquipmentInfo, EffectiveLength.MaxContainer, Double)? {

View File

@@ -353,30 +353,24 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
case .deleteRectangularSize(let roomID, let rectangularSizeID):
let room = try await database.rooms.deleteRectangularSize(roomID, rectangularSizeID)
let container = try await manualD.calculate(
rooms: [room],
designFrictionRateResult: database.designFrictionRate(projectID: projectID),
projectSHR: database.projects.getSensibleHeatRatio(projectID)
).first!
let container = try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
.first!
return DuctSizingView.RoomRow(projectID: projectID, room: container)
case .roomRectangularForm(let roomID, let form):
let _ = try await database.rooms.update(
let room = try await database.rooms.update(
roomID,
.init(rectangularSizes: [.init(register: form.register, height: form.height)])
.init(
rectangularSizes: [
.init(id: form.id ?? .init(), register: form.register, height: form.height)
]
)
)
// request.logger.debug("Got room rectangular form: \(roomID)")
//
// let containers = try await manualD.calculate(
// rooms: [room],
// designFrictionRateResult: database.designFrictionRate(projectID: projectID),
// projectSHR: database.projects.getSensibleHeatRatio(projectID)
// )
// request.logger.debug("Room Containers: \(containers)")
// let container = containers.first(where: { $0.roomName == "\(room.name)-\(form.register)" })!
// request.logger.debug("Room Container: \(container)")
// return DuctSizingView.RoomRow(projectID: projectID, room: container)
return ProjectView(projectID: projectID, activeTab: .ductSizing, logger: request.logger)
let container = try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
.first!
return DuctSizingView.RoomRow(projectID: projectID, room: container)
}
}
}

View File

@@ -31,10 +31,13 @@ struct ComponentPressureLossesView: HTML, Sendable {
span(.class("text-sm italic")) { "Total" }
}
}
PlusButton()
.attributes(
.showModal(id: ComponentLossForm.id())
)
Tooltip("Add Component Loss") {
PlusButton()
.attributes(
.class("btn-ghost text-2xl"),
.showModal(id: ComponentLossForm.id())
)
}
}
table(.class("table table-zebra")) {
@@ -65,24 +68,28 @@ struct ComponentPressureLossesView: HTML, Sendable {
td { Number(row.value) }
td {
div(.class("flex join items-end justify-end mx-auto")) {
TrashButton()
.attributes(
.class("join-item"),
.hx.delete(
route: .project(
.detail(row.projectID, .componentLoss(.delete(row.id)))
)
),
.hx.target("body"),
.hx.swap(.outerHTML),
.hx.confirm("Are your sure?")
Tooltip("Delete", position: .bottom) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(
route: .project(
.detail(row.projectID, .componentLoss(.delete(row.id)))
)
),
.hx.target("body"),
.hx.swap(.outerHTML),
.hx.confirm("Are your sure?")
)
EditButton()
.attributes(
.class("join-item"),
.showModal(id: ComponentLossForm.id(row))
)
)
}
Tooltip("Edit", position: .bottom) {
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: ComponentLossForm.id(row))
)
}
}
ComponentLossForm(dismiss: true, projectID: row.projectID, componentLoss: row)

View File

@@ -7,7 +7,9 @@ import Styleguide
struct DuctSizingView: HTML, Sendable {
let projectID: Project.ID
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
var body: some HTML {

View File

@@ -74,7 +74,7 @@ struct RectangularSizeForm: HTML, Sendable {
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("body"),
.hx.target("closest tr"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(register))

View File

@@ -3,16 +3,8 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: May need a multi-step form were the the effective length type is
// determined before groups selections are made in order to use the
// appropriate select field values when the type is supply vs. return.
// Currently when the select field is changed it doesn't change the group
// I can get it to add a new one.
// TODO: Add back buttons / capability??
// TODO: Add patch / update capability
struct EffectiveLengthForm: HTML, Sendable {
static func id(_ equivalentLength: EffectiveLength?) -> String {

View File

@@ -7,7 +7,9 @@ import Styleguide
struct EffectiveLengthsView: HTML, Sendable {
let projectID: Project.ID
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let effectiveLengths: [EffectiveLength]
var supplies: [EffectiveLength] {

View File

@@ -15,10 +15,13 @@ struct EquipmentInfoView: HTML, Sendable {
Row {
h1(.class("text-2xl font-bold")) { "Equipment Info" }
EditButton()
.attributes(
.on(.click, "\(EquipmentInfoForm.id).showModal()")
)
Tooltip("Edit equipment info") {
EditButton()
.attributes(
.class("btn-ghost"),
.showModal(id: EquipmentInfoForm.id)
)
}
}
if let equipmentInfo {

View File

@@ -1,4 +1,5 @@
import Elementary
import ManualDClient
import ManualDCore
import Styleguide
@@ -6,23 +7,20 @@ import Styleguide
struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let equipmentInfo: EquipmentInfo?
let componentLosses: [ComponentPressureLoss]
let equivalentLengths: EffectiveLength.MaxContainer
let projectID: Project.ID
// let projectID: Project.ID
let frictionRateResponse: ManualDClient.FrictionRateResponse?
var availableStaticPressure: Double? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}
return staticPressure - componentLosses.totalComponentPressureLoss
frictionRateResponse?.availableStaticPressure
}
var frictionRateDesignValue: Double? {
guard let availableStaticPressure, let tel = equivalentLengths.total else {
return nil
}
return (((availableStaticPressure * 100) / tel) * 100) / 100
frictionRateResponse?.frictionRate
}
var badgeColor: String {

View File

@@ -7,6 +7,10 @@ import ManualDClient
import ManualDCore
import Styleguide
enum ProjectViewValue {
@TaskLocal static var projectID = Project.ID(0)
}
struct ProjectView: HTML, Sendable {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
@@ -34,50 +38,65 @@ struct ProjectView: HTML, Sendable {
div(.class("drawer-content p-4")) {
label(
.for("my-drawer-1"),
.class("btn btn-square btn-ghost drawer-button size-7")
.class("btn btn-square btn-ghost drawer-button size-7 pb-6")
) {
SVG(.sidebarToggle)
}
switch self.activeTab {
case .project:
if let project = try await database.projects.get(projectID) {
ProjectDetail(project: project)
} else {
div {
"FIX ME!"
await resultView(projectID) {
guard let project = try await database.projects.get(projectID) else {
throw NotFoundError()
}
return project
} onSuccess: { project in
ProjectDetail(project: project)
}
case .rooms:
try await RoomsView(
projectID: projectID,
rooms: database.rooms.fetch(projectID),
sensibleHeatRatio: database.projects.getSensibleHeatRatio(projectID)
)
await resultView(projectID) {
try await (
database.rooms.fetch(projectID),
database.projects.getSensibleHeatRatio(projectID)
)
} onSuccess: { (rooms, shr) in
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
}
case .equivalentLength:
try await EffectiveLengthsView(
projectID: projectID,
effectiveLengths: database.effectiveLength.fetch(projectID)
)
await resultView(projectID) {
try await database.effectiveLength.fetch(projectID)
} onSuccess: {
EffectiveLengthsView(effectiveLengths: $0)
}
case .frictionRate:
try await FrictionRateView(
equipmentInfo: database.equipment.fetch(projectID),
componentLosses: database.componentLoss.fetch(projectID),
equivalentLengths: database.effectiveLength.fetchMax(projectID),
projectID: projectID
)
case .ductSizing:
try await DuctSizingView(
projectID: projectID,
rooms: manualD.calculate(
rooms: database.rooms.fetch(projectID),
designFrictionRateResult: database.designFrictionRate(projectID: projectID),
projectSHR: database.projects.getSensibleHeatRatio(projectID),
logger: logger
)
)
// div { "FIX ME!" }
await resultView(projectID) {
let equipmentInfo = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let equivalentLengths = try await database.effectiveLength.fetchMax(projectID)
let frictionRateResponse = try await manualD.frictionRate(
equipmentInfo: equipmentInfo,
componentLosses: componentLosses,
effectiveLength: equivalentLengths
)
return (
equipmentInfo, componentLosses, equivalentLengths, frictionRateResponse
)
} onSuccess: {
FrictionRateView(
equipmentInfo: $0.0,
componentLosses: $0.1,
equivalentLengths: $0.2,
frictionRateResponse: $0.3
)
}
case .ductSizing:
await resultView(projectID) {
try await database.calculateDuctSizes(projectID: projectID)
} onSuccess: {
DuctSizingView(rooms: $0)
}
}
}
@@ -89,8 +108,77 @@ struct ProjectView: HTML, Sendable {
}
}
}
func resultView<V: Sendable, E: Error, ValueView: HTML>(
_ projectID: Project.ID,
catching: @escaping @Sendable () async throws(E) -> V,
onSuccess: @escaping @Sendable (V) -> ValueView
) async -> ResultView<V, E, _ModifiedTaskLocal<Project.ID, ValueView>, ErrorView<E>>
where
ValueView: Sendable, E: Sendable
{
await .init(
result: .init(catching: catching),
onSuccess: { result in
onSuccess(result)
.environment(ProjectViewValue.$projectID, projectID)
}
)
}
}
// extension SiteRoute.View.ProjectRoute.DetailRoute.Tab {
//
// func view(projectID: Project.ID) async throws -> AnySendableHTML {
// @Dependency(\.database) var database
// @Dependency(\.manualD) var manualD
//
// switch self {
// case .project:
// if let project = try await database.projects.get(projectID) {
// return ProjectDetail(project: project)
// } else {
// return div {
// "FIX ME!"
// }
// }
// case .rooms:
// return try await RoomsView(
// projectID: projectID,
// rooms: database.rooms.fetch(projectID),
// sensibleHeatRatio: database.projects.getSensibleHeatRatio(projectID)
// )
//
// case .equivalentLength:
// return try await EffectiveLengthsView(
// projectID: projectID,
// effectiveLengths: database.effectiveLength.fetch(projectID)
// )
// case .frictionRate:
// let equipmentInfo = try await database.equipment.fetch(projectID)
// let componentLosses = try await database.componentLoss.fetch(projectID)
// let equivalentLengths = try await database.effectiveLength.fetchMax(projectID)
//
// return try await FrictionRateView(
// equipmentInfo: equipmentInfo,
// componentLosses: componentLosses,
// equivalentLengths: equivalentLengths,
// projectID: projectID,
// frictionRateResponse: manualD.frictionRate(
// equipmentInfo: equipmentInfo,
// componentLosses: componentLosses,
// effectiveLength: equivalentLengths
// )
// )
// case .ductSizing:
// return try await DuctSizingView(
// projectID: projectID,
// rooms: database.calculateDuctSizes(projectID: projectID)
// )
// }
// }
// }
extension ProjectView {
struct Sidebar: HTML {
@@ -261,3 +349,27 @@ extension ProjectView {
}
}
}
extension ManualDClient {
func frictionRate(
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
) async throws -> FrictionRateResponse? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}
guard let totalEquivalentLength = effectiveLength.total else {
return nil
}
return try await self.frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
)
)
}
}

View File

@@ -5,40 +5,36 @@ import Foundation
import ManualDCore
import Styleguide
// TODO: Calculate rooms sensible based on project wide SHR.
struct RoomsView: HTML, Sendable {
let projectID: Project.ID
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [Room]
let sensibleHeatRatio: Double?
var body: some HTML {
div {
Row {
h1(.class("text-2xl font-bold")) { "Room Loads" }
// div(
// .class("tooltip tooltip-left"),
// .data("tip", value: "Add room")
// ) {
// div(.class("flex me-4")) {
// PlusButton()
// .attributes(.showModal(id: RoomForm.id()))
// }
// }
}
.attributes(.class("pb-6"))
h1(.class("text-2xl font-bold pb-6")) { "Room Loads" }
div(.class("border rounded-lg mb-6")) {
Row {
div(.class("space-x-6 my-2")) {
Label("Sensible Heat Ratio")
if let sensibleHeatRatio {
Number(sensibleHeatRatio)
div {
div(.class("space-x-6 my-2 items-center")) {
Label("Sensible Heat Ratio")
.attributes(.class("my-auto"))
if let sensibleHeatRatio {
Number(sensibleHeatRatio)
.attributes(.class("badge badge-outline"))
}
}
p(.class("text-sm italic")) {
"Project wide sensible heat ratio"
}
}
EditButton()
.attributes(.showModal(id: SHRForm.id))
Tooltip("Edit SHR") {
EditButton()
.attributes(.showModal(id: SHRForm.id))
}
}
.attributes(.class("m-4"))
@@ -134,7 +130,7 @@ struct RoomsView: HTML, Sendable {
td {
div(.class("flex justify-end")) {
div(.class("join")) {
Tooltip("Delete room") {
Tooltip("Delete room", position: .bottom) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
@@ -144,16 +140,14 @@ struct RoomsView: HTML, Sendable {
.hx.confirm("Are you sure?")
)
}
.attributes(.class("tooltip-bottom"))
Tooltip("Edit room") {
Tooltip("Edit room", position: .bottom) {
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RoomForm.id(room))
)
}
.attributes(.class("tooltip-bottom"))
}
}
RoomForm(