feat: Begins htmx integration

This commit is contained in:
2025-02-27 12:38:08 -05:00
parent 36e00cd007
commit 3e17bf2a9a
10 changed files with 227 additions and 49 deletions

View File

@@ -79,10 +79,12 @@ let package = Package(
.target( .target(
name: "ViewController", name: "ViewController",
dependencies: [ dependencies: [
"ApiController",
"Routes", "Routes",
"Styleguide", "Styleguide",
.product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Elementary", package: "elementary") .product(name: "Elementary", package: "elementary"),
.product(name: "ElementaryHTMX", package: "elementary-htmx")
], ],
swiftSettings: swiftSettings swiftSettings: swiftSettings
) )

File diff suppressed because one or more lines are too long

View File

@@ -2,15 +2,15 @@ import Logging
import PsychrometricClient import PsychrometricClient
import Routes import Routes
extension PsychrometricClient { public extension PsychrometricClient {
private func calculateProperties(_ request: MoldRisk.Request) async throws -> PsychrometricProperties { private func calculateProperties(_ request: MoldRisk.Request) async throws -> PsychrometricProperties {
try await psychrometricProperties(.dryBulb(request.temperature, relativeHumidity: request.humidity)) try await psychrometricProperties(.dryBulb(request.dryBulb, relativeHumidity: request.relativeHumidity))
} }
func respond(_ request: MoldRisk.Request, _ logger: Logger) async throws -> MoldRisk.Response { func respond(_ request: MoldRisk.Request, _ logger: Logger? = nil) async throws -> MoldRisk.Response {
let properties = try await calculateProperties(request) let properties = try await calculateProperties(request)
let riskLevel = MoldRisk.RiskLevel(humidity: request.humidity) let riskLevel = MoldRisk.RiskLevel(humidity: request.relativeHumidity)
return .init( return .init(
psychrometricProperties: properties, psychrometricProperties: properties,
@@ -56,7 +56,7 @@ private extension Array where Element == String {
"Reduce indoor relative humidity below 60% using dehumidification" "Reduce indoor relative humidity below 60% using dehumidification"
) )
} }
if (request.temperature.fahrenheit - dewPoint.fahrenheit) < 4 { if (request.dryBulb.fahrenheit - dewPoint.fahrenheit) < 4 {
recommendations.append( recommendations.append(
"Increase air temperature or improve insulation to prevent condensation" "Increase air temperature or improve insulation to prevent condensation"
) )

View File

@@ -6,16 +6,11 @@ import ViewController
extension ViewController { extension ViewController {
func respond(route: SiteRoute.View, request: Vapor.Request) async throws -> any AsyncResponseEncodable { func respond(route: SiteRoute.View, request: Vapor.Request) async throws -> any AsyncResponseEncodable {
// let html = try await view( let html = try await view(.init(
// for: route, route,
// isHtmxRequest: request.isHtmxRequest, isHtmxRequest: request.headers.contains(name: "hx-request"),
// logger: request.logger, logger: request.logger
// authenticate: { request.session.authenticate($0) }, ))
// currentUser: {
// try request.auth.require(User.self)
// }
// )
let html = try await view(route)
return AnyHTMLResponse(value: html) return AnyHTMLResponse(value: html)
} }
} }

View File

@@ -4,10 +4,10 @@ import PsychrometricClient
public enum MoldRisk { public enum MoldRisk {
public struct Request: Codable, Equatable, Sendable { public struct Request: Codable, Equatable, Sendable {
public let temperature: DryBulb public let temperature: Double
public let humidity: RelativeHumidity public let humidity: Double
public init(temperature: DryBulb, humidity: RelativeHumidity) { public init(temperature: Double, humidity: Double) {
self.temperature = temperature self.temperature = temperature
self.humidity = humidity self.humidity = humidity
} }
@@ -41,6 +41,15 @@ public enum MoldRisk {
} }
} }
public extension MoldRisk.Request {
var dryBulb: DryBulb {
.fahrenheit(temperature)
}
var relativeHumidity: RelativeHumidity { humidity% }
}
#if DEBUG #if DEBUG
import Dependencies import Dependencies

View File

@@ -1,5 +1,6 @@
import CasePaths import CasePaths
import Foundation import Foundation
import PsychrometricClient
@preconcurrency import URLRouting @preconcurrency import URLRouting
public enum SiteRoute: Equatable, Sendable { public enum SiteRoute: Equatable, Sendable {
@@ -52,6 +53,7 @@ public extension SiteRoute {
public enum MoldRisk: Equatable, Sendable { public enum MoldRisk: Equatable, Sendable {
case index case index
case submit(Routes.MoldRisk.Request)
static let rootPath = "mold-risk" static let rootPath = "mold-risk"
@@ -60,6 +62,22 @@ public extension SiteRoute {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("temperature") {
Double.parser()
}
Field("humidity") {
Double.parser()
}
}
.map(.memberwise(Routes.MoldRisk.Request.init))
}
}
} }
} }
} }

View File

@@ -0,0 +1,30 @@
import Elementary
import ElementaryHTMX
import Routes
extension HTMLAttribute.hx {
@Sendable
static func get(route: SiteRoute.View) -> HTMLAttribute {
get(SiteRoute.View.router.path(for: route))
}
@Sendable
static func patch(route: SiteRoute.View) -> HTMLAttribute {
patch(SiteRoute.View.router.path(for: route))
}
@Sendable
static func post(route: SiteRoute.View) -> HTMLAttribute {
post(SiteRoute.View.router.path(for: route))
}
@Sendable
static func put(route: SiteRoute.View) -> HTMLAttribute {
put(SiteRoute.View.router.path(for: route))
}
@Sendable
static func delete(route: SiteRoute.Api) -> HTMLAttribute {
delete(SiteRoute.Api.router.path(for: route))
}
}

View File

@@ -1,6 +1,9 @@
import ApiController
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Elementary import Elementary
import Logging
import PsychrometricClient
import Routes import Routes
public extension DependencyValues { public extension DependencyValues {
@@ -14,7 +17,23 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient @DependencyClient
public struct ViewController: Sendable { public struct ViewController: Sendable {
public var view: @Sendable (SiteRoute.View) async throws -> AnySendableHTML public var view: @Sendable (Request) async throws -> AnySendableHTML
public struct Request: Sendable {
let route: SiteRoute.View
let isHtmxRequest: Bool
let logger: Logger
public init(
_ route: SiteRoute.View,
isHtmxRequest: Bool,
logger: Logger
) {
self.route = route
self.isHtmxRequest = isHtmxRequest
self.logger = logger
}
}
} }
extension ViewController: TestDependencyKey { extension ViewController: TestDependencyKey {
@@ -23,10 +42,37 @@ extension ViewController: TestDependencyKey {
extension ViewController: DependencyKey { extension ViewController: DependencyKey {
public static var liveValue: ViewController { public static var liveValue: ViewController {
.init(view: { _ in @Dependency(\.psychrometricClient) var psychrometricClient
MainPage {
MoldRiskForm() return .init(view: { request in
switch request.route {
case .index:
return MainPage {
p(.class("dark:text-gray-200")) {
"Professional calculators for HVAC system design and troubleshooting."
}
}
case let .moldRisk(route):
switch route {
case .index:
return request.respond(MoldRiskForm(response: nil))
case let .submit(request):
let response = try await psychrometricClient.respond(request)
return MoldRiskResponse(response: response)
}
} }
}) })
} }
} }
extension ViewController.Request {
func respond<C: HTML>(
_ html: C
) -> AnySendableHTML where C: Sendable {
guard isHtmxRequest else {
return MainPage { html }
}
return html
}
}

View File

@@ -28,6 +28,7 @@ struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
.init(name: "sizes", value: "180x180") .init(name: "sizes", value: "180x180")
) )
link(.rel(.init(rawValue: "mainifest")), .href("/site.webmanifest")) link(.rel(.init(rawValue: "mainifest")), .href("/site.webmanifest"))
script(.src("https://unpkg.com/htmx.org@2.0.4")) {}
} }
var body: some HTML { var body: some HTML {
@@ -59,7 +60,10 @@ private struct Header: HTML {
// TODO: Add class active, to button that is the active route. // TODO: Add class active, to button that is the active route.
ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) { ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) {
li { li {
a(.href(route: .moldRisk(.index)), .class("hover:border-b \(border: .yellow)")) { a(
.class("hover:border-b \(border: .yellow)"),
.hx.get(route: .moldRisk(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Mold-Risk" "Mold-Risk"
} }
} }
@@ -80,7 +84,9 @@ private struct PageContent<Body: HTML>: HTML where Body: Sendable {
var content: some HTML { var content: some HTML {
div(.class("mx-5 lg:mx-20")) { div(.class("mx-5 lg:mx-20")) {
div(.class("rounded-xl shadow-lg bg-white dark:bg-slate-700 p-8")) { div(.class("rounded-xl shadow-lg bg-white dark:bg-slate-700 p-8")) {
body() div(.id("content")) {
body()
}
} }
} }
} }

View File

@@ -5,9 +5,15 @@ import Styleguide
struct MoldRiskForm: HTML { struct MoldRiskForm: HTML {
let response: MoldRisk.Response?
var content: some HTML { var content: some HTML {
FormHeader(label: "Mold Risk Calculator", svg: .thermometer) FormHeader(label: "Mold Risk Calculator", svg: .thermometer)
form(.action("#")) { form(
// Using index to get the correct path, but really uses the `submit` end-point.
.hx.post(route: .moldRisk(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) { div(.class("space-y-6")) {
LabeledContent(label: "Indoor Temperature (°F)") { LabeledContent(label: "Indoor Temperature (°F)") {
Input(id: "temperature", placeholder: "Dry bulb temperature") Input(id: "temperature", placeholder: "Dry bulb temperature")
@@ -22,10 +28,11 @@ struct MoldRiskForm: HTML {
div { div {
SubmitButton(label: "Calculate Mold Risk") SubmitButton(label: "Calculate Mold Risk")
} }
}
div(.id("result")) { }
MoldRiskResponse(response: .mock) div(.id("result")) {
} if let response {
MoldRiskResponse(response: response)
} }
} }
} }
@@ -36,21 +43,51 @@ struct MoldRiskResponse: HTML {
var content: some HTML { var content: some HTML {
ResultContainer { ResultContainer {
// TODO: Color needs to be derived from risk level. div(
div(.class("p-2 rounded-lg shadow-lg bg-lime-100 border-2 border border-lime-600")) { .class("""
LabeledContent { p-2 rounded-lg shadow-lg \(response.riskLevel.backgroundColor) border-2 border \(response.riskLevel.borderColor)
p(.class("text-lg font-semibold \(text: .green) dark:text-lime-600 mt-2")) { """)
"Risk Level: \(response.riskLevel.rawValue.capitalized)" ) {
// Risk level and days to mold header.
div(.class("\(response.riskLevel.textColor)")) {
div(.class("flex flex-wrap mt-2")) {
div(.class("w-full sm:w-1/2 flex gap-2")) {
SVG(.exclamation, color: "\(response.riskLevel.textColor)")
span(.class("text-lg font-extrabold")) {
"Risk Level: \(response.riskLevel.rawValue.capitalized)"
}
}
if let daysToMold = response.daysToMold {
div(.class("w-full sm:w-1/2 gap-2")) {
span(.class("font-semibold")) { "Estimated Days to Mold Growth: " }
span { "\(daysToMold) days" }
}
}
}
// Recommendations
if response.recommendations.count > 0 {
div(.class("mt-6 pb-4")) {
p(.class("font-semibold mb-4")) {
u {
"Recommendation\(response.recommendations.count == 1 ? "" : "s"):"
}
}
ul(.class("list-disc mx-10")) {
for recommendation in response.recommendations {
li { recommendation }
}
}
}
} }
} label: {
SVG(.exclamation, color: "text-green-600 dark:text-lime-600")
} }
.attributes(.class("flex items-center gap-2"))
} }
// Display psychrometric properties.
PsychrometricPropertiesGrid(properties: response.psychrometricProperties) PsychrometricPropertiesGrid(properties: response.psychrometricProperties)
.attributes(.class("mx-6")) .attributes(.class("mx-6"))
// Disclaimer.
div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) { div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) {
p(.class("text-sm text-blue-500")) { p(.class("text-sm text-blue-500")) {
span(.class("font-extrabold pe-2")) { "Note:" } span(.class("font-extrabold pe-2")) { "Note:" }
@@ -65,20 +102,55 @@ struct MoldRiskResponse: HTML {
} }
} }
private extension MoldRisk.RiskLevel {
var backgroundColor: String {
switch self {
case .low: return "bg-green-200"
case .moderate: return "bg-amber-200"
case .high: return "bg-orange-200"
case .severe: return "bg-red-200"
}
}
var borderColor: String {
switch self {
case .low: return "border-green-500"
case .moderate: return "border-amber-500"
case .high: return "border-orange-500"
case .severe: return "border-red-500"
}
}
var textColor: String {
switch self {
case .low: return "text-green-500"
case .moderate: return "text-amber-500"
case .high: return "text-orange-500"
case .severe: return "text-red-500"
}
}
}
struct PsychrometricPropertiesGrid: HTML { struct PsychrometricPropertiesGrid: HTML {
let properties: PsychrometricProperties let properties: PsychrometricProperties
var content: some HTML<HTMLTag.div> { var content: some HTML<HTMLTag.div> {
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6 md:mx-20")) { div(.class("pt-8")) {
displayProperty("Dew Point", \.dewPoint.rawValue) p(.class("text-xl font-semibold border-b")) {
displayProperty("Wet Bulb", \.wetBulb.rawValue) "Psychrometric Properties:"
displayProperty("Enthalpy", \.enthalpy.rawValue) }
displayProperty("Density", \.density.rawValue) div(.class("w-full mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) {
displayProperty("Vapor Pressure", \.vaporPressure.rawValue) displayProperty("Dew Point", \.dewPoint.rawValue)
displayProperty("Specific Volume", properties.specificVolume.rawValue) displayProperty("Wet Bulb", \.wetBulb.rawValue)
displayProperty("Absolute Humidity", \.absoluteHumidity) displayProperty("Enthalpy", \.enthalpy.rawValue)
displayProperty("Humidity Ratio", properties.humidityRatio.value) displayProperty("Density", \.density.rawValue)
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value) displayProperty("Vapor Pressure", \.vaporPressure.rawValue)
displayProperty("Specific Volume", properties.specificVolume.rawValue)
displayProperty("Absolute Humidity", \.absoluteHumidity)
displayProperty("Humidity Ratio", properties.humidityRatio.value)
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value)
}
} }
} }