39 Commits

Author SHA1 Message Date
f5afc6e32b feat: Adds release workflow.
All checks were successful
Create and publish a Docker image / build-and-push-image (push) Successful in 8m45s
2026-01-15 15:33:31 -05:00
9709eaaf8e feat: Removes register-id in favor of using the room name with register number in duct sizing forms / tables. 2026-01-15 15:18:42 -05:00
4ecd4dba7b feat: Style updates, begins adding name/label to trunk sizes. Need to remove register id. 2026-01-15 13:00:46 -05:00
7471e11bd2 fix: Fixes some layout issues with footer and sidebar, makes size column in duct-sizing views to be a fixed width, so the tables line up properly. 2026-01-15 09:17:27 -05:00
1b88f81b5f feat: Adds page header styles, starts an Alert component. 2026-01-14 23:09:28 -05:00
86307dfa05 feat: Uses room names for trunk sizing in the form and table, prep for removing register-id's in favor of only using the room names. 2026-01-14 19:24:56 -05:00
356e020e3b fix: Fixes trunk / runout rectangular size badge not matching color of room rectangular size. 2026-01-14 19:05:49 -05:00
9b5b891744 feat: Adds footer with copyright info. 2026-01-14 19:02:10 -05:00
658ea9f12e feat: Adds meta tags for og / twitter links. 2026-01-14 18:29:19 -05:00
7f734e912b fix: Fixes rectangular duct rounding. 2026-01-14 17:04:27 -05:00
b5d1f87380 feat: Adds manual-d group pdf while working on better picker for groups, fixes issues with trunk table not always rendering properly with certain themes. 2026-01-14 16:53:05 -05:00
450791b37e fix: Fixes duct sizing rooms table not showing forms correctly, updates the table styles. 2026-01-14 10:32:57 -05:00
71848c607a WIP: Rooms table style updates in duct sizing tab, but room form is not working properly on all rows for some reason. 2026-01-13 22:47:50 -05:00
62a82ed674 fix: Fixes height / width not working for trunk sizes. 2026-01-13 20:36:52 -05:00
dfee50de8e feat-WIP: Adds update to trunk-size form, currently height / width is not working though. 2026-01-13 20:23:25 -05:00
f990c4b6db WIP: Begin cleaning up duct sizing routes. 2026-01-13 17:01:44 -05:00
930db145a8 WIP: Begins trunk sizing, adds database and core models. 2026-01-13 11:45:27 -05:00
df600a5471 feat: Updates forms to use LabeledInput, style updates. 2026-01-13 10:15:06 -05:00
432533c940 feat-WIP: Style updates, new form inputs. 2026-01-12 22:49:58 -05:00
fa9e8cffb0 feat: Fixes signup flow, begins updating form input fields. 2026-01-12 18:53:45 -05:00
c2aedfac1a WIP: Begins updating signup route to also setup a user's profile. 2026-01-12 17:04:51 -05:00
894bd561ff feat: Begins user profile, adds database model, need to add views / forms. 2026-01-12 13:33:53 -05:00
6416b29627 feat: Removes unused form routes. 2026-01-12 11:32:38 -05:00
6aaf39f63c fix: Fixes database going out of scope when rendering project view. 2026-01-12 10:58:57 -05:00
0a68177aa8 feat: Style updates. 2026-01-11 20:57:06 -05:00
f7c6373255 feat: Adds initial icons / favicon 2026-01-11 13:48:30 -05:00
f835fc7c51 feat: Initial navbar 2026-01-11 12:41:54 -05:00
51edff5a8a feat: Updates sidebar styles. 2026-01-11 10:35:49 -05:00
a7f40efba9 feat: Updates button styles. 2026-01-10 20:37:57 -05:00
1446540109 feat: Updates to using ResultView to handle errors. 2026-01-10 20:14:59 -05:00
20065ebf10 feat: Adds result view to better handle errors, integrates it into project view. 2026-01-10 18:27:45 -05:00
a356aa2a13 feat: Updates rectangular size to be a modal form, some style updates to other views. 2026-01-10 14:04:23 -05:00
07818d24ed WIP: Inital duct rectangular form, needs better thought out. 2026-01-09 17:03:00 -05:00
7083178844 feat: Initial duct sizing view and calculations, need to add supply and return trunk sizing. 2026-01-09 12:43:56 -05:00
30fddb9dce feat: Updates form routes and database routes to use id's in the url path. 2026-01-09 09:25:37 -05:00
9356ccb1c9 feat: Updates sidebar to use the drawer classes from daisyui, currently doesn't open automatically on large screens like I want. 2026-01-08 12:40:05 -05:00
79b7892d9a feat: Adds update path to equivalent length form / database / view routes. 2026-01-07 17:31:54 -05:00
f8bed40670 feat: Adds multi-step form to generate equivalent lengths for a project. 2026-01-07 11:56:04 -05:00
dbf7e3b1b4 feat: Some style updates, form improvements on project-room view. 2026-01-06 16:58:42 -05:00
92 changed files with 7418 additions and 1052 deletions

View File

@@ -0,0 +1,65 @@
name: Create and publish a Docker image
# Configures this workflow to run every time a change is pushed to the branch called `release`.
on:
push:
# branches: ['main']
tags:
- '*.*.*'
workflow_dispatch:
# Defines two custom environment variables for the workflow. These are used for the Container registry domain,
# and a name for the Docker image that this workflow builds.
env:
REGISTRY: git.housh.dev
IMAGE_NAME: ${{ gitea.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Uses the `docker/login-action` action to log in to the Container registry registry using the account
# and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.CONTAINER_TOKEN }}
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels
# that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a
# subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
type=raw,value=prod
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"
# in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -105,6 +105,7 @@ let package = Package(
name: "ViewController", name: "ViewController",
dependencies: [ dependencies: [
.target(name: "DatabaseClient"), .target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"), .target(name: "ManualDCore"),
.target(name: "Styleguide"), .target(name: "Styleguide"),
.product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Dependencies", package: "swift-dependencies"),

View File

@@ -1,5 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: light --default, dark --prefersdark, dracula; themes: all;
} }

File diff suppressed because it is too large Load Diff

BIN
Public/files/ManD.Groups.pdf Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
Public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
Public/images/mand_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

1
Public/site.webmanifest Normal file
View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -31,6 +31,13 @@ extension SiteRoute.Api.ProjectRoute {
case .delete(let id): case .delete(let id):
try await database.projects.delete(id) try await database.projects.delete(id)
return nil return nil
case .detail(let id, let route):
switch route {
case .completedSteps:
// FIX:
fatalError()
}
case .get(let id): case .get(let id):
guard let project = try await database.projects.get(id) else { guard let project = try await database.projects.get(id) else {
logger.error("Project not found for id: \(id)") logger.error("Project not found for id: \(id)")

View File

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

View File

@@ -12,6 +12,9 @@ extension DatabaseClient {
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss] public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss? public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
public var update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
} }
} }
@@ -43,6 +46,17 @@ extension DatabaseClient.ComponentLoss {
}, },
get: { id in get: { id in
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() } try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
try updates.validate()
guard let model = try await ComponentLossModel.find(id, on: database) else {
throw NotFoundError()
}
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
} }
) )
} }
@@ -68,6 +82,24 @@ extension ComponentPressureLoss.Create {
} }
} }
extension ComponentPressureLoss.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Component loss name should not be empty.")
}
}
if let value {
guard value > 0 else {
throw ValidationError("Component loss value should be greater than 0.")
}
guard value < 1.0 else {
throw ValidationError("Component loss value should be less than 1.0.")
}
}
}
}
extension ComponentPressureLoss { extension ComponentPressureLoss {
struct Migrate: AsyncMigration { struct Migrate: AsyncMigration {
let name = "CreateComponentLoss" let name = "CreateComponentLoss"
@@ -142,4 +174,13 @@ final class ComponentLossModel: Model, @unchecked Sendable {
updatedAt: updatedAt! updatedAt: updatedAt!
) )
} }
func applyUpdates(_ updates: ComponentPressureLoss.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let value = updates.value, value != self.value {
self.value = value
}
}
} }

View File

@@ -10,7 +10,10 @@ extension DatabaseClient {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength] public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength? public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
public var update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
} }
} }
@@ -37,8 +40,35 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
.all() .all()
.map { try $0.toDTO() } .map { try $0.toDTO() }
}, },
fetchMax: { projectID in
let effectiveLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
return .init(
supply: effectiveLengths.filter({ $0.type == .supply })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first,
return: effectiveLengths.filter({ $0.type == .return })
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
.first
)
},
get: { id in get: { id in
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() } try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
throw NotFoundError()
}
try model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
} }
) )
} }
@@ -155,4 +185,19 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
updatedAt: updatedAt! updatedAt: updatedAt!
) )
} }
func applyUpdates(_ updates: EffectiveLength.Update) throws {
if let name = updates.name, name != self.name {
self.name = name
}
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
self.straightLengths = straightLengths
}
if let groups = updates.groups {
self.groups = try JSONEncoder().encode(groups)
}
}
} }

View File

@@ -11,7 +11,8 @@ extension DatabaseClient {
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo? public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo? public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update: @Sendable (EquipmentInfo.Update) async throws -> EquipmentInfo public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
} }
} }
@@ -46,13 +47,15 @@ extension DatabaseClient.Equipment {
get: { id in get: { id in
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() } try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
}, },
update: { request in update: { id, updates in
guard let model = try await EquipmentModel.find(request.id, on: database) else { guard let model = try await EquipmentModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
guard request.hasUpdates else { return try model.toDTO() } try updates.validate()
try model.applyUpdates(request) model.applyUpdates(updates)
try await model.save(on: database) if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO() return try model.toDTO()
} }
) )
@@ -196,8 +199,7 @@ final class EquipmentModel: Model, @unchecked Sendable {
) )
} }
func applyUpdates(_ updates: EquipmentInfo.Update) throws { func applyUpdates(_ updates: EquipmentInfo.Update) {
try updates.validate()
if let staticPressure = updates.staticPressure { if let staticPressure = updates.staticPressure {
self.staticPressure = staticPressure self.staticPressure = staticPressure
} }

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
// TODO: Move to ManualDCore
public struct ValidationError: Error { public struct ValidationError: Error {
public let message: String public let message: String
@@ -8,4 +9,6 @@ public struct ValidationError: Error {
} }
} }
public struct NotFoundError: Error {} public struct NotFoundError: Error {
public init() {}
}

View File

@@ -19,6 +19,8 @@ public struct DatabaseClient: Sendable {
public var componentLoss: ComponentLoss public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient public var effectiveLength: EffectiveLengthClient
public var users: Users public var users: Users
public var userProfile: UserProfile
public var trunkSizes: TrunkSizes
} }
extension DatabaseClient: TestDependencyKey { extension DatabaseClient: TestDependencyKey {
@@ -29,7 +31,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue, equipment: .testValue,
componentLoss: .testValue, componentLoss: .testValue,
effectiveLength: .testValue, effectiveLength: .testValue,
users: .testValue users: .testValue,
userProfile: .testValue,
trunkSizes: .testValue
) )
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
@@ -40,7 +44,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database), equipment: .live(database: database),
componentLoss: .live(database: database), componentLoss: .live(database: database),
effectiveLength: .live(database: database), effectiveLength: .live(database: database),
users: .live(database: database) users: .live(database: database),
userProfile: .live(database: database),
trunkSizes: .live(database: database)
) )
} }
} }
@@ -63,10 +69,12 @@ extension DatabaseClient.Migrations: DependencyKey {
Project.Migrate(), Project.Migrate(),
User.Migrate(), User.Migrate(),
User.Token.Migrate(), User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(), ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(), EquipmentInfo.Migrate(),
Room.Migrate(), Room.Migrate(),
EffectiveLength.Migrate(), EffectiveLength.Migrate(),
DuctSizing.TrunkSize.Migrate(),
] ]
} }
) )

View File

@@ -10,9 +10,10 @@ extension DatabaseClient {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void public var delete: @Sendable (Project.ID) async throws -> Void
public var get: @Sendable (Project.ID) async throws -> Project? 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? public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project> public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.Update) async throws -> Project public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
} }
} }
@@ -35,6 +36,42 @@ extension DatabaseClient.Projects: TestDependencyKey {
get: { id in get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() } try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
}, },
getCompletedSteps: { id in
let roomsCount = try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.all()
var equivalentLengthsCompleted = false
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
equivalentLengths.filter({ $0.type == "return" }).first != nil
{
equivalentLengthsCompleted = true
}
let componentLosses = try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.count()
let equipmentInfo = try await EquipmentModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id == id)
.first()
return .init(
equipmentInfo: equipmentInfo != nil,
rooms: roomsCount > 0,
equivalentLength: equivalentLengthsCompleted,
frictionRate: componentLosses > 0
)
},
getSensibleHeatRatio: { id in getSensibleHeatRatio: { id in
guard let model = try await ProjectModel.find(id, on: database) else { guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
@@ -49,12 +86,13 @@ extension DatabaseClient.Projects: TestDependencyKey {
.paginate(request) .paginate(request)
.map { try $0.toDTO() } .map { try $0.toDTO() }
}, },
update: { updates in update: { id, updates in
guard let model = try await ProjectModel.find(updates.id, on: database) else { guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate() try updates.validate()
if model.applyUpdates(updates) { model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database) try await model.save(on: database)
} }
return try model.toDTO() return try model.toDTO()
@@ -247,34 +285,26 @@ final class ProjectModel: Model, @unchecked Sendable {
) )
} }
func applyUpdates(_ updates: Project.Update) -> Bool { func applyUpdates(_ updates: Project.Update) {
var hasUpdates = false
if let name = updates.name, name != self.name { if let name = updates.name, name != self.name {
hasUpdates = true
self.name = name self.name = name
} }
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress { if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
hasUpdates = true
self.streetAddress = streetAddress self.streetAddress = streetAddress
} }
if let city = updates.city, city != self.city { if let city = updates.city, city != self.city {
hasUpdates = true
self.city = city self.city = city
} }
if let state = updates.state, state != self.state { if let state = updates.state, state != self.state {
hasUpdates = true
self.state = state self.state = state
} }
if let zipCode = updates.zipCode, zipCode != self.zipCode { if let zipCode = updates.zipCode, zipCode != self.zipCode {
hasUpdates = true
self.zipCode = zipCode self.zipCode = zipCode
} }
if let sensibleHeatRatio = updates.sensibleHeatRatio, if let sensibleHeatRatio = updates.sensibleHeatRatio,
sensibleHeatRatio != self.sensibleHeatRatio sensibleHeatRatio != self.sensibleHeatRatio
{ {
hasUpdates = true
self.sensibleHeatRatio = sensibleHeatRatio self.sensibleHeatRatio = sensibleHeatRatio
} }
return hasUpdates
} }
} }

View File

@@ -0,0 +1,175 @@
// import Dependencies
// import DependenciesMacros
// import Fluent
// import Foundation
// import ManualDCore
//
// extension DatabaseClient {
// @DependencyClient
// public struct RectangularDuct: Sendable {
// public var create:
// @Sendable (DuctSizing.RectangularDuct.Create) async throws -> DuctSizing.RectangularDuct
// public var delete: @Sendable (DuctSizing.RectangularDuct.ID) async throws -> Void
// public var fetch: @Sendable (Room.ID) async throws -> [DuctSizing.RectangularDuct]
// public var get:
// @Sendable (DuctSizing.RectangularDuct.ID) async throws -> DuctSizing.RectangularDuct?
// public var update:
// @Sendable (DuctSizing.RectangularDuct.ID, DuctSizing.RectangularDuct.Update) async throws ->
// DuctSizing.RectangularDuct
// }
// }
//
// extension DatabaseClient.RectangularDuct: TestDependencyKey {
// public static let testValue = Self()
//
// public static func live(database: any Database) -> Self {
// .init(
// create: { request in
// try request.validate()
// let model = request.toModel()
// try await model.save(on: database)
// return try model.toDTO()
// },
// delete: { id in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try await model.delete(on: database)
// },
// fetch: { roomID in
// try await RectangularDuctModel.query(on: database)
// .with(\.$room)
// .filter(\.$room.$id == roomID)
// .all()
// .map { try $0.toDTO() }
// },
// get: { id in
// try await RectangularDuctModel.find(id, on: database)
// .map { try $0.toDTO() }
// },
// update: { id, updates in
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
// throw NotFoundError()
// }
// try updates.validate()
// model.applyUpdates(updates)
// if model.hasChanges {
// try await model.save(on: database)
// }
// return try model.toDTO()
// }
// )
// }
// }
//
// extension DuctSizing.RectangularDuct.Create {
//
// func validate() throws(ValidationError) {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
//
// func toModel() -> RectangularDuctModel {
// .init(roomID: roomID, height: height)
// }
// }
//
// extension DuctSizing.RectangularDuct.Update {
//
// func validate() throws(ValidationError) {
// if let height {
// guard height > 0 else {
// throw ValidationError("Rectangular duct size height should be greater than 0.")
// }
// }
// if let register {
// guard register > 0 else {
// throw ValidationError("Rectangular duct size register should be greater than 0.")
// }
// }
// }
// }
//
// extension DuctSizing.RectangularDuct {
// struct Migrate: AsyncMigration {
// let name = "CreateRectangularDuct"
//
// func prepare(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema)
// .id()
// .field("register", .int8)
// .field("height", .int8, .required)
// .field("roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade))
// .field("createdAt", .datetime)
// .field("updatedAt", .datetime)
// .create()
// }
//
// func revert(on database: any Database) async throws {
// try await database.schema(RectangularDuctModel.schema).delete()
// }
// }
// }
//
// final class RectangularDuctModel: Model, @unchecked Sendable {
//
// static let schema = "rectangularDuct"
//
// @ID(key: .id)
// var id: UUID?
//
// @Parent(key: "roomID")
// var room: RoomModel
//
// @Field(key: "height")
// var height: Int
//
// @Field(key: "register")
// var register: Int?
//
// @Timestamp(key: "createdAt", on: .create, format: .iso8601)
// var createdAt: Date?
//
// @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
// var updatedAt: Date?
//
// init() {}
//
// init(
// id: UUID? = nil,
// roomID: Room.ID,
// register: Int? = nil,
// height: Int
// ) {
// self.id = id
// $room.id = roomID
// self.register = register
// self.height = height
// }
//
// func toDTO() throws -> DuctSizing.RectangularDuct {
// return try .init(
// id: requireID(),
// roomID: $room.id,
// register: register,
// height: height,
// createdAt: createdAt!,
// updatedAt: updatedAt!
// )
// }
//
// func applyUpdates(_ updates: DuctSizing.RectangularDuct.Update) {
// if let height = updates.height, height != self.height {
// self.height = height
// }
// if let register = updates.register, register != self.register {
// self.register = register
// }
// }
// }

View File

@@ -9,9 +9,13 @@ extension DatabaseClient {
public struct Rooms: Sendable { public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room public var create: @Sendable (Room.Create) async throws -> Room
public var delete: @Sendable (Room.ID) async throws -> Void public var delete: @Sendable (Room.ID) async throws -> Void
public var deleteRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct.ID) async throws -> Room
public var get: @Sendable (Room.ID) async throws -> Room? public var get: @Sendable (Room.ID) async throws -> Room?
public var fetch: @Sendable (Project.ID) async throws -> [Room] public var fetch: @Sendable (Project.ID) async throws -> [Room]
public var update: @Sendable (Room.Update) async throws -> Room public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
public var updateRectangularSize:
@Sendable (Room.ID, DuctSizing.RectangularDuct) async throws -> Room
} }
} }
@@ -31,6 +35,18 @@ extension DatabaseClient.Rooms: TestDependencyKey {
} }
try await model.delete(on: database) try await model.delete(on: database)
}, },
deleteRectangularSize: { roomID, rectangularDuctID in
guard let model = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
model.rectangularSizes?.removeAll {
$0.id == rectangularDuctID
}
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
},
get: { id in get: { id in
try await RoomModel.find(id, on: database).map { try $0.toDTO() } try await RoomModel.find(id, on: database).map { try $0.toDTO() }
}, },
@@ -38,19 +54,34 @@ extension DatabaseClient.Rooms: TestDependencyKey {
try await RoomModel.query(on: database) try await RoomModel.query(on: database)
.with(\.$project) .with(\.$project)
.filter(\.$project.$id, .equal, projectID) .filter(\.$project.$id, .equal, projectID)
.sort(\.$name, .ascending)
.all() .all()
.map { try $0.toDTO() } .map { try $0.toDTO() }
}, },
update: { updates in update: { id, updates in
guard let model = try await RoomModel.find(updates.id, on: database) else { guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError() throw NotFoundError()
} }
try updates.validate() try updates.validate()
if model.applyUpdates(updates) { model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database) try await model.save(on: database)
} }
return try model.toDTO() return try model.toDTO()
},
updateRectangularSize: { id, size in
guard let model = try await RoomModel.find(id, on: database) else {
throw NotFoundError()
}
var rectangularSizes = model.rectangularSizes ?? []
rectangularSizes.removeAll {
$0.id == size.id
}
rectangularSizes.append(size)
model.rectangularSizes = rectangularSizes
try await model.save(on: database)
return try model.toDTO()
} }
) )
} }
@@ -134,6 +165,7 @@ extension Room {
.field("coolingTotal", .double, .required) .field("coolingTotal", .double, .required)
.field("coolingSensible", .double) .field("coolingSensible", .double)
.field("registerCount", .int8, .required) .field("registerCount", .int8, .required)
.field("rectangularSizes", .array)
.field("createdAt", .datetime) .field("createdAt", .datetime)
.field("updatedAt", .datetime) .field("updatedAt", .datetime)
.field( .field(
@@ -171,6 +203,9 @@ final class RoomModel: Model, @unchecked Sendable {
@Field(key: "registerCount") @Field(key: "registerCount")
var registerCount: Int var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601) @Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date? var createdAt: Date?
@@ -189,6 +224,7 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: Double, coolingTotal: Double,
coolingSensible: Double? = nil, coolingSensible: Double? = nil,
registerCount: Int, registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
createdAt: Date? = nil, createdAt: Date? = nil,
updatedAt: Date? = nil, updatedAt: Date? = nil,
projectID: Project.ID projectID: Project.ID
@@ -199,6 +235,7 @@ final class RoomModel: Model, @unchecked Sendable {
self.coolingTotal = coolingTotal self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible self.coolingSensible = coolingSensible
self.registerCount = registerCount self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
$project.id = projectID $project.id = projectID
@@ -213,35 +250,33 @@ final class RoomModel: Model, @unchecked Sendable {
coolingTotal: coolingTotal, coolingTotal: coolingTotal,
coolingSensible: coolingSensible, coolingSensible: coolingSensible,
registerCount: registerCount, registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!, createdAt: createdAt!,
updatedAt: updatedAt! updatedAt: updatedAt!
) )
} }
func applyUpdates(_ updates: Room.Update) -> Bool { func applyUpdates(_ updates: Room.Update) {
var hasUpdates = false
if let name = updates.name, name != self.name { if let name = updates.name, name != self.name {
hasUpdates = true
self.name = name self.name = name
} }
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad { if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
hasUpdates = true
self.heatingLoad = heatingLoad self.heatingLoad = heatingLoad
} }
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal { if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
hasUpdates = true
self.coolingTotal = coolingTotal self.coolingTotal = coolingTotal
} }
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible { if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
hasUpdates = true
self.coolingSensible = coolingSensible self.coolingSensible = coolingSensible
} }
if let registerCount = updates.registerCount, registerCount != self.registerCount { if let registerCount = updates.registerCount, registerCount != self.registerCount {
hasUpdates = true
self.registerCount = registerCount self.registerCount = registerCount
} }
return hasUpdates if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
self.rectangularSizes = rectangularSizes
}
} }
} }

View File

@@ -0,0 +1,356 @@
import Dependencies
import DependenciesMacros
import Fluent
import Foundation
import ManualDCore
extension DatabaseClient {
@DependencyClient
public struct TrunkSizes: Sendable {
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
public var update:
@Sendable (DuctSizing.TrunkSize.ID, DuctSizing.TrunkSize.Update) async throws ->
DuctSizing.TrunkSize
}
}
extension DatabaseClient.TrunkSizes: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
try request.validate()
let trunk = request.toModel()
var roomProxies = [DuctSizing.TrunkSize.RoomProxy]()
try await trunk.save(on: database)
for (roomID, registers) in request.rooms {
guard let room = try await RoomModel.find(roomID, on: database) else {
throw NotFoundError()
}
let model = try TrunkRoomModel(
trunkID: trunk.requireID(),
roomID: room.requireID(),
registers: registers,
type: request.type
)
try await model.save(on: database)
try await roomProxies.append(model.toDTO(on: database))
}
return try .init(
id: trunk.requireID(),
projectID: trunk.$project.id,
type: .init(rawValue: trunk.type)!,
rooms: roomProxies
)
},
delete: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$project)
.with(\.$rooms)
.filter(\.$project.$id == projectID)
.all()
.toDTO(on: database)
},
get: { id in
guard let model = try await TrunkModel.find(id, on: database) else {
return nil
}
return try await model.toDTO(on: database)
},
update: { id, updates in
guard
let model =
try await TrunkModel
.query(on: database)
.with(\.$rooms)
.filter(\.$id == id)
.first()
else {
throw NotFoundError()
}
try updates.validate()
try await model.applyUpdates(updates, on: database)
return try await model.toDTO(on: database)
}
)
}
}
extension DuctSizing.TrunkSize.Create {
func validate() throws(ValidationError) {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
func toModel() -> TrunkModel {
.init(
projectID: projectID,
type: type,
height: height,
name: name
)
}
}
extension DuctSizing.TrunkSize.Update {
func validate() throws(ValidationError) {
if let rooms {
guard rooms.count > 0 else {
throw ValidationError("Trunk size should have associated rooms / registers.")
}
}
if let height {
guard height > 0 else {
throw ValidationError("Trunk size height should be greater than 0.")
}
}
}
}
extension DuctSizing.TrunkSize {
struct Migrate: AsyncMigration {
let name = "CreateTrunkSize"
func prepare(on database: any Database) async throws {
try await database.schema(TrunkModel.schema)
.id()
.field("height", .int8)
.field("name", .string)
.field("type", .string, .required)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.create()
try await database.schema(TrunkRoomModel.schema)
.id()
.field("registers", .array(of: .int), .required)
.field("type", .string, .required)
.field(
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
)
.field(
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
)
.unique(on: "trunkID", "roomID", "type")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(TrunkRoomModel.schema).delete()
try await database.schema(TrunkModel.schema).delete()
}
}
}
// Pivot table for associating rooms and trunks.
final class TrunkRoomModel: Model, @unchecked Sendable {
static let schema = "room+trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "trunkID")
var trunk: TrunkModel
@Parent(key: "roomID")
var room: RoomModel
@Field(key: "registers")
var registers: [Int]
@Field(key: "type")
var type: String
init() {}
init(
id: UUID? = nil,
trunkID: TrunkModel.IDValue,
roomID: RoomModel.IDValue,
registers: [Int],
type: DuctSizing.TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
self.type = type.rawValue
}
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy {
guard let room = try await RoomModel.find($room.id, on: database) else {
throw NotFoundError()
}
return .init(
room: try room.toDTO(),
registers: registers
)
}
}
final class TrunkModel: Model, @unchecked Sendable {
static let schema = "trunk"
@ID(key: .id)
var id: UUID?
@Parent(key: "projectID")
var project: ProjectModel
@OptionalField(key: "height")
var height: Int?
@Field(key: "type")
var type: String
@OptionalField(key: "name")
var name: String?
@Children(for: \.$trunk)
var rooms: [TrunkRoomModel]
init() {}
init(
id: UUID? = nil,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
height: Int? = nil,
name: String? = nil
) {
self.id = id
$project.id = projectID
self.height = height
self.type = type.rawValue
self.name = name
}
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize {
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in
for room in self.rooms {
group.addTask {
try await room.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
$0.append($1)
}
}
return try .init(
id: requireID(),
projectID: $project.id,
type: .init(rawValue: type)!,
rooms: rooms,
height: height,
name: name
)
}
func applyUpdates(
_ updates: DuctSizing.TrunkSize.Update,
on database: any Database
) async throws {
if let type = updates.type, type.rawValue != self.type {
self.type = type.rawValue
}
if let height = updates.height, height != self.height {
self.height = height
}
if let name = updates.name, name != self.name {
self.name = name
}
if hasChanges {
try await self.save(on: database)
}
guard let updateRooms = updates.rooms else {
return
}
// Update rooms.
let rooms = try await TrunkRoomModel.query(on: database)
.with(\.$room)
.filter(\.$trunk.$id == requireID())
.all()
for (roomID, registers) in updateRooms {
if let currRoom = rooms.first(where: { $0.$room.id == roomID }) {
database.logger.debug("CURRENT ROOM: \(currRoom.room.name)")
if registers != currRoom.registers {
database.logger.debug("Updating registers for: \(currRoom.room.name)")
currRoom.registers = registers
}
if currRoom.hasChanges {
try await currRoom.save(on: database)
}
} else {
database.logger.debug("CREATING NEW TrunkRoomModel")
let newModel = try TrunkRoomModel(
trunkID: requireID(),
roomID: roomID,
registers: registers,
type: .init(rawValue: type)!
)
try await newModel.save(on: database)
}
}
let roomsToDelete = rooms.filter {
!updateRooms.keys.contains($0.$room.id)
}
for room in roomsToDelete {
try await room.delete(on: database)
}
database.logger.debug("DONE WITH UPDATES")
}
}
extension Array where Element == TrunkModel {
func toDTO(on database: any Database) async throws -> [DuctSizing.TrunkSize] {
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
for model in self {
group.addTask {
try await model.toDTO(on: database)
}
}
return try await group.reduce(into: [DuctSizing.TrunkSize]()) {
$0.append($1)
}
}
}
}

View File

@@ -0,0 +1,283 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct UserProfile: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
}
extension DatabaseClient.UserProfile: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
try profile.validate()
let model = profile.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
fetch: { userID in
try await UserProfileModel.query(on: database)
.with(\.$user)
.filter(\.$user.$id == userID)
.first()
.map { try $0.toDTO() }
},
get: { id in
try await UserProfileModel.find(id, on: database)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
func toModel() -> UserProfileModel {
.init(
userID: userID,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: theme
)
}
}
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
if let companyName {
guard !companyName.isEmpty else {
throw ValidationError("User company name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("User street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("User city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("User state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("User zip code should not be empty.")
}
}
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
let name = "Create UserProfile"
func prepare(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema)
.id()
.field("firstName", .string, .required)
.field("lastName", .string, .required)
.field("companyName", .string, .required)
.field("streetAddress", .string, .required)
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("theme", .string)
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "userID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema).delete()
}
}
}
final class UserProfileModel: Model, @unchecked Sendable {
static let schema = "user_profile"
@ID(key: .id)
var id: UUID?
@Parent(key: "userID")
var user: UserModel
@Field(key: "firstName")
var firstName: String
@Field(key: "lastName")
var lastName: String
@Field(key: "companyName")
var companyName: String
@Field(key: "streetAddress")
var streetAddress: String
@Field(key: "city")
var city: String
@Field(key: "state")
var state: String
@Field(key: "zipCode")
var zipCode: String
@Field(key: "theme")
var theme: String?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.id = id
$user.id = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme?.rawValue
}
func toDTO() throws -> User.Profile {
try .init(
id: requireID(),
userID: $user.id,
firstName: firstName,
lastName: lastName,
companyName: companyName,
streetAddress: streetAddress,
city: city,
state: state,
zipCode: zipCode,
theme: self.theme.flatMap(Theme.init),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: User.Profile.Update) {
if let firstName = updates.firstName, firstName != self.firstName {
self.firstName = firstName
}
if let lastName = updates.lastName, lastName != self.lastName {
self.lastName = lastName
}
if let companyName = updates.companyName, companyName != self.companyName {
self.companyName = companyName
}
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
self.streetAddress = streetAddress
}
if let city = updates.city, city != self.city {
self.city = city
}
if let state = updates.state, state != self.state {
self.state = state
}
if let zipCode = updates.zipCode, zipCode != self.zipCode {
self.zipCode = zipCode
}
if let theme = updates.theme, theme.rawValue != self.theme {
self.theme = theme.rawValue
}
}
}

View File

@@ -80,12 +80,11 @@ extension User {
func prepare(on database: any Database) async throws { func prepare(on database: any Database) async throws {
try await database.schema(UserModel.schema) try await database.schema(UserModel.schema)
.id() .id()
.field("username", .string, .required)
.field("email", .string, .required) .field("email", .string, .required)
.field("password_hash", .string, .required) .field("password_hash", .string, .required)
.field("createdAt", .datetime) .field("createdAt", .datetime)
.field("updatedAt", .datetime) .field("updatedAt", .datetime)
.unique(on: "email", "username") .unique(on: "email")
.create() .create()
} }
@@ -104,6 +103,8 @@ extension User.Token {
.id() .id()
.field("value", .string, .required) .field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id")) .field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value") .unique(on: "value")
.create() .create()
} }
@@ -126,13 +127,10 @@ extension User.Create {
func toModel() throws -> UserModel { func toModel() throws -> UserModel {
try validate() try validate()
return try .init(username: username, email: email, passwordHash: User.hashPassword(password)) return try .init(email: email, passwordHash: User.hashPassword(password))
} }
func validate() throws { func validate() throws {
guard !username.isEmpty else {
throw ValidationError("Username should not be empty.")
}
guard !email.isEmpty else { guard !email.isEmpty else {
throw ValidationError("Email should not be empty") throw ValidationError("Email should not be empty")
} }
@@ -152,9 +150,6 @@ final class UserModel: Model, @unchecked Sendable {
@ID(key: .id) @ID(key: .id)
var id: UUID? var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "email") @Field(key: "email")
var email: String var email: String
@@ -174,12 +169,10 @@ final class UserModel: Model, @unchecked Sendable {
init( init(
id: UUID? = nil, id: UUID? = nil,
username: String,
email: String, email: String,
passwordHash: String passwordHash: String
) { ) {
self.id = id self.id = id
self.username = username
self.email = email self.email = email
self.passwordHash = passwordHash self.passwordHash = passwordHash
} }
@@ -188,7 +181,6 @@ final class UserModel: Model, @unchecked Sendable {
try .init( try .init(
id: requireID(), id: requireID(),
email: email, email: email,
username: username,
createdAt: createdAt!, createdAt: createdAt!,
updatedAt: updatedAt! updatedAt: updatedAt!
) )
@@ -237,7 +229,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable {
extension User: Authenticatable {} extension User: Authenticatable {}
extension User: SessionAuthenticatable { extension User: SessionAuthenticatable {
public var sessionID: String { username } public var sessionID: String { email }
} }
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator { public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
@@ -248,7 +240,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public func authenticate(basic: BasicAuthorization, for request: Request) async throws { public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
guard guard
let user = try await UserModel.query(on: request.db) let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == basic.username) .filter(\UserModel.$email == basic.username)
.first(), .first(),
try user.verifyPassword(basic.password) try user.verifyPassword(basic.password)
else { else {
@@ -284,7 +276,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public func authenticate(sessionID: User.SessionID, for request: Request) async throws { public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
guard guard
let user = try await UserModel.query(on: request.db) let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == sessionID) .filter(\UserModel.$email == sessionID)
.first() .first()
else { else {
throw Abort(.unauthorized) throw Abort(.unauthorized)

View File

@@ -1,6 +1,51 @@
import Foundation import Foundation
import ManualDCore import ManualDCore
extension Room {
var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
return sensible / Double(registerCount)
}
}
extension DuctSizing.TrunkSize.RoomProxy {
// We need to make sure if registers got removed after a trunk
// was already made / saved that we do not include registers that
// no longer exist.
private var actualRegisterCount: Int {
guard registers.count <= room.registerCount else {
return room.registerCount
}
return registers.count
}
var totalHeatingLoad: Double {
room.heatingLoadPerRegister * Double(actualRegisterCount)
}
func totalCoolingSensible(projectSHR: Double) -> Double {
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
extension DuctSizing.TrunkSize {
var totalHeatingLoad: Double {
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
}
func totalCoolingSensible(projectSHR: Double) -> Double {
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
}
}
extension ComponentPressureLosses { extension ComponentPressureLosses {
var totalLosses: Double { values.reduce(0) { $0 + $1 } } var totalLosses: Double { values.reduce(0) { $0 + $1 } }
} }
@@ -19,6 +64,8 @@ func roundSize(_ size: Double) throws -> Int {
throw ManualDError(message: "Size should be less than 24.") throw ManualDError(message: "Size should be less than 24.")
} }
// let size = size.rounded(.toNearestOrEven)
switch size { switch size {
case 0..<4: case 0..<4:
return 4 return 4

View File

@@ -25,7 +25,7 @@ extension ManualDClient: DependencyKey {
throw ManualDError(message: "Total Effective Length should be greater than 0.") throw ManualDError(message: "Total Effective Length should be greater than 0.")
} }
let totalComponentLosses = request.componentPressureLosses.totalLosses let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength) let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate) return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
@@ -38,23 +38,7 @@ extension ManualDClient: DependencyKey {
}, },
equivalentRectangularDuct: { request in equivalentRectangularDuct: { request in
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height) let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
// Round the width up or fail (really should never fail since we know the input is a number). return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
guard let widthStr = numberFormatter.string(for: width),
let widthInt = Int(widthStr)
else {
throw ManualDError(
message: "Failed to convert to to rectangular duct size, width: \(width)"
)
}
return .init(height: request.height, width: widthInt)
} }
) )
} }
private let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = 0
formatter.minimumFractionDigits = 0
formatter.roundingMode = .ceiling
return formatter
}()

View File

@@ -1,5 +1,6 @@
import Dependencies import Dependencies
import DependenciesMacros import DependenciesMacros
import Logging
import ManualDCore import ManualDCore
@DependencyClient @DependencyClient
@@ -9,6 +10,146 @@ public struct ManualDClient: Sendable {
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct: public var equivalentRectangularDuct:
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse @Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
public func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
try await (
calculateSizes(
rooms: rooms, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR
),
calculateSizes(
rooms: rooms, trunks: trunks, equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
designFrictionRate: designFrictionRate, projectSHR: projectSHR)
)
}
func calculateSizes(
rooms: [Room],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
var retval: [DuctSizing.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
)
for n in 1...room.registerCount {
var rectangularWidth: Int? = nil
let rectangularSize = room.rectangularSizes?
.first(where: { $0.register == nil || $0.register == n })
if let rectangularSize {
let response = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: rectangularSize.height)
)
rectangularWidth = response.width
}
retval.append(
.init(
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
coolingCFM: coolingCFM,
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
rectangularSize: rectangularSize,
rectangularWidth: rectangularWidth
)
)
}
}
return retval
}
func calculateSizes(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
equipmentInfo: EquipmentInfo,
maxSupplyLength: EffectiveLength,
maxReturnLength: EffectiveLength,
designFrictionRate: Double,
projectSHR: Double,
logger: Logger? = nil
) async throws -> [DuctSizing.TrunkContainer] {
var retval = [DuctSizing.TrunkContainer]()
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
for trunk in trunks {
let heatingLoad = trunk.totalHeatingLoad
let coolingLoad = trunk.totalCoolingSensible(projectSHR: projectSHR)
let heatingPercent = heatingLoad / totalHeatingLoad
let coolingPercent = coolingLoad / totalCoolingSensible
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
let sizes = try await self.ductSize(
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
)
var width: Int? = nil
if let height = trunk.height {
let rectangularSize = try await self.equivalentRectangularDuct(
.init(round: sizes.finalSize, height: height)
)
width = rectangularSize.width
}
retval.append(
.init(
trunk: trunk,
ductSize: .init(
designCFM: designCFM,
roundSize: sizes.ductulatorSize,
finalSize: sizes.finalSize,
velocity: sizes.velocity,
flexSize: sizes.flexSize,
height: trunk.height,
width: width
)
)
)
}
return retval
}
} }
extension ManualDClient: TestDependencyKey { extension ManualDClient: TestDependencyKey {
@@ -63,12 +204,12 @@ extension ManualDClient {
public struct FrictionRateRequest: Codable, Equatable, Sendable { public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double public let externalStaticPressure: Double
public let componentPressureLosses: ComponentPressureLosses public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int public let totalEffectiveLength: Int
public init( public init(
externalStaticPressure: Double, externalStaticPressure: Double,
componentPressureLosses: ComponentPressureLosses, componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int totalEffectiveLength: Int
) { ) {
self.externalStaticPressure = externalStaticPressure self.externalStaticPressure = externalStaticPressure

View File

@@ -52,6 +52,26 @@ extension ComponentPressureLoss {
] ]
} }
} }
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let value: Double?
public init(
name: String? = nil,
value: Double? = nil
) {
self.name = name
self.value = value
}
}
}
extension Array where Element == ComponentPressureLoss {
public var total: Double {
reduce(into: 0) { $0 += $1.value }
}
} }
public typealias ComponentPressureLosses = [String: Double] public typealias ComponentPressureLosses = [String: Double]

View File

@@ -0,0 +1,248 @@
import Dependencies
import Foundation
public enum DuctSizing {
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let register: Int?
public let height: Int
public init(
id: UUID = .init(),
register: Int? = nil,
height: Int,
) {
self.id = id
self.register = register
self.height = height
}
}
public struct SizeContainer: Codable, Equatable, Sendable {
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let height: Int?
public let width: Int?
public init(
designCFM: DuctSizing.DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
height: Int? = nil,
width: Int? = nil
) {
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.height = height
self.width = width
}
}
// TODO: Uses SizeContainer
public struct RoomContainer: Codable, Equatable, Sendable {
public let roomID: Room.ID
public let roomName: String
public let roomRegister: Int
public let heatingLoad: Double
public let coolingLoad: Double
public let heatingCFM: Double
public let coolingCFM: Double
public let designCFM: DesignCFM
public let roundSize: Double
public let finalSize: Int
public let velocity: Int
public let flexSize: Int
public let rectangularSize: RectangularDuct?
public let rectangularWidth: Int?
public init(
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
coolingCFM: Double,
designCFM: DesignCFM,
roundSize: Double,
finalSize: Int,
velocity: Int,
flexSize: Int,
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
self.designCFM = designCFM
self.roundSize = roundSize
self.finalSize = finalSize
self.velocity = velocity
self.flexSize = flexSize
self.rectangularSize = rectangularSize
self.rectangularWidth = rectangularWidth
}
}
public enum DesignCFM: Codable, Equatable, Sendable {
case heating(Double)
case cooling(Double)
public init(heating: Double, cooling: Double) {
if heating >= cooling {
self = .heating(heating)
} else {
self = .cooling(cooling)
}
}
public var value: Double {
switch self {
case .heating(let value): return value
case .cooling(let value): return value
}
}
}
}
extension DuctSizing {
// Represents the database model that the duct sizes have been calculated
// for.
@dynamicMemberLookup
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
public var id: TrunkSize.ID { trunk.id }
public let trunk: TrunkSize
public let ductSize: SizeContainer
public init(
trunk: TrunkSize,
ductSize: SizeContainer
) {
self.trunk = trunk
self.ductSize = ductSize
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.TrunkSize, T>) -> T {
trunk[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.SizeContainer, T>) -> T {
ductSize[keyPath: keyPath]
}
}
// TODO: Add an optional label that the user can set.
// Represents the database model.
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [RoomProxy]
public let height: Int?
public let name: String?
public init(
id: UUID,
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [DuctSizing.TrunkSize.RoomProxy],
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
}
extension DuctSizing.TrunkSize {
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let type: TrunkType
public let rooms: [Room.ID: [Int]]
public let height: Int?
public let name: String?
public init(
projectID: Project.ID,
type: DuctSizing.TrunkSize.TrunkType,
rooms: [Room.ID: [Int]],
height: Int? = nil,
name: String? = nil
) {
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
public struct Update: Codable, Equatable, Sendable {
public let type: TrunkType?
public let rooms: [Room.ID: [Int]]?
public let height: Int?
public let name: String?
public init(
type: DuctSizing.TrunkSize.TrunkType? = nil,
rooms: [Room.ID: [Int]]? = nil,
height: Int? = nil,
name: String? = nil
) {
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
// TODO: Make registers non-optional
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}

View File

@@ -61,6 +61,26 @@ extension EffectiveLength {
} }
} }
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let type: EffectiveLengthType?
public let straightLengths: [Int]?
public let groups: [Group]?
public init(
name: String? = nil,
type: EffectiveLength.EffectiveLengthType? = nil,
straightLengths: [Int]? = nil,
groups: [EffectiveLength.Group]? = nil
) {
self.name = name
self.type = type
self.straightLengths = straightLengths
self.groups = groups
}
}
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable { public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
case `return` case `return`
case supply case supply
@@ -85,6 +105,38 @@ extension EffectiveLength {
self.quantity = quantity self.quantity = quantity
} }
} }
public struct MaxContainer: Codable, Equatable, Sendable {
public let supply: EffectiveLength?
public let `return`: EffectiveLength?
public var total: Double? {
guard let supply else { return nil }
guard let `return` else { return nil }
return supply.totalEquivalentLength + `return`.totalEquivalentLength
}
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
self.supply = supply
self.return = `return`
}
}
}
extension EffectiveLength {
public var totalEquivalentLength: Double {
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
+ groups.totalEquivalentLength
}
}
extension Array where Element == EffectiveLength.Group {
public var totalEquivalentLength: Double {
reduce(into: 0.0) {
$0 += ($1.value * Double($1.quantity))
}
}
} }
#if DEBUG #if DEBUG

View File

@@ -52,18 +52,15 @@ extension EquipmentInfo {
} }
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
public let id: EquipmentInfo.ID
public let staticPressure: Double? public let staticPressure: Double?
public let heatingCFM: Int? public let heatingCFM: Int?
public let coolingCFM: Int? public let coolingCFM: Int?
public init( public init(
id: EquipmentInfo.ID,
staticPressure: Double? = nil, staticPressure: Double? = nil,
heatingCFM: Int? = nil, heatingCFM: Int? = nil,
coolingCFM: Int? = nil coolingCFM: Int? = nil
) { ) {
self.id = id
self.staticPressure = staticPressure self.staticPressure = staticPressure
self.heatingCFM = heatingCFM self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM self.coolingCFM = coolingCFM

View File

@@ -64,9 +64,28 @@ extension Project {
} }
} }
public struct CompletedSteps: Codable, Equatable, Sendable {
public let equipmentInfo: Bool
public let rooms: Bool
public let equivalentLength: Bool
public let frictionRate: Bool
public init(
equipmentInfo: Bool,
rooms: Bool,
equivalentLength: Bool,
frictionRate: Bool
) {
self.equipmentInfo = equipmentInfo
self.rooms = rooms
self.equivalentLength = equivalentLength
self.frictionRate = frictionRate
}
}
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
public let id: Project.ID
public let name: String? public let name: String?
public let streetAddress: String? public let streetAddress: String?
public let city: String? public let city: String?
@@ -75,7 +94,6 @@ extension Project {
public let sensibleHeatRatio: Double? public let sensibleHeatRatio: Double?
public init( public init(
id: Project.ID,
name: String? = nil, name: String? = nil,
streetAddress: String? = nil, streetAddress: String? = nil,
city: String? = nil, city: String? = nil,
@@ -83,7 +101,6 @@ extension Project {
zipCode: String? = nil, zipCode: String? = nil,
sensibleHeatRatio: Double? = nil sensibleHeatRatio: Double? = nil
) { ) {
self.id = id
self.name = name self.name = name
self.streetAddress = streetAddress self.streetAddress = streetAddress
self.city = city self.city = city

View File

@@ -9,6 +9,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let coolingTotal: Double public let coolingTotal: Double
public let coolingSensible: Double? public let coolingSensible: Double?
public let registerCount: Int public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let createdAt: Date public let createdAt: Date
public let updatedAt: Date public let updatedAt: Date
@@ -20,6 +21,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
coolingTotal: Double, coolingTotal: Double,
coolingSensible: Double? = nil, coolingSensible: Double? = nil,
registerCount: Int = 1, registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
createdAt: Date, createdAt: Date,
updatedAt: Date updatedAt: Date
) { ) {
@@ -30,6 +32,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.coolingTotal = coolingTotal self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible self.coolingSensible = coolingSensible
self.registerCount = registerCount self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
@@ -63,27 +66,55 @@ extension Room {
} }
public struct Update: Codable, Equatable, Sendable { public struct Update: Codable, Equatable, Sendable {
public let id: Room.ID
public let name: String? public let name: String?
public let heatingLoad: Double? public let heatingLoad: Double?
public let coolingTotal: Double? public let coolingTotal: Double?
public let coolingSensible: Double? public let coolingSensible: Double?
public let registerCount: Int? public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public init( public init(
id: Room.ID,
name: String? = nil, name: String? = nil,
heatingLoad: Double? = nil, heatingLoad: Double? = nil,
coolingTotal: Double? = nil, coolingTotal: Double? = nil,
coolingSensible: Double? = nil, coolingSensible: Double? = nil,
registerCount: Int? = nil registerCount: Int? = nil
) { ) {
self.id = id
self.name = name self.name = name
self.heatingLoad = heatingLoad self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible self.coolingSensible = coolingSensible
self.registerCount = registerCount self.registerCount = registerCount
self.rectangularSizes = nil
}
public init(
rectangularSizes: [DuctSizing.RectangularDuct]
) {
self.name = nil
self.heatingLoad = nil
self.coolingTotal = nil
self.coolingSensible = nil
self.registerCount = nil
self.rectangularSizes = rectangularSizes
}
}
}
extension Array where Element == Room {
public var totalHeatingLoad: Double {
reduce(into: 0) { $0 += $1.heatingLoad }
}
public var totalCoolingLoad: Double {
reduce(into: 0) { $0 += $1.coolingTotal }
}
public func totalCoolingSensible(shr: Double) -> Double {
reduce(into: 0) {
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
$0 += sensible
} }
} }
} }

View File

@@ -45,6 +45,7 @@ extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable { public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create) case create(Project.Create)
case delete(id: Project.ID) case delete(id: Project.ID)
case detail(id: Project.ID, route: DetailRoute)
case get(id: Project.ID) case get(id: Project.ID)
case index case index
@@ -74,6 +75,31 @@ extension SiteRoute.Api {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.detail(id:route:))) {
Path {
rootPath
Project.ID.parser()
}
DetailRoute.router
}
}
}
}
extension SiteRoute.Api.ProjectRoute {
public enum DetailRoute: Equatable, Sendable {
case completedSteps
static let rootPath = "details"
static let router = OneOf {
Route(.case(Self.completedSteps)) {
Path {
rootPath
"completed"
}
Method.get
}
} }
} }
} }

View File

@@ -11,9 +11,15 @@ extension SiteRoute {
case login(LoginRoute) case login(LoginRoute)
case signup(SignupRoute) case signup(SignupRoute)
case project(ProjectRoute) case project(ProjectRoute)
case effectiveLength(EffectiveLengthRoute) case user(UserRoute)
//FIX: Remove.
case test
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.test)) {
Path { "test" }
Method.get
}
Route(.case(Self.login)) { Route(.case(Self.login)) {
SiteRoute.View.LoginRoute.router SiteRoute.View.LoginRoute.router
} }
@@ -23,8 +29,8 @@ extension SiteRoute {
Route(.case(Self.project)) { Route(.case(Self.project)) {
SiteRoute.View.ProjectRoute.router SiteRoute.View.ProjectRoute.router
} }
Route(.case(Self.effectiveLength)) { Route(.case(Self.user)) {
SiteRoute.View.EffectiveLengthRoute.router SiteRoute.View.UserRoute.router
} }
} }
} }
@@ -35,10 +41,9 @@ extension SiteRoute.View {
case create(Project.Create) case create(Project.Create)
case delete(id: Project.ID) case delete(id: Project.ID)
case detail(Project.ID, DetailRoute) case detail(Project.ID, DetailRoute)
case form(id: Project.ID? = nil, dismiss: Bool = false)
case index case index
case page(PageRequest) case page(PageRequest)
case update(Project.Update) case update(Project.ID, Project.Update)
public static func page(page: Int, per limit: Int) -> Self { public static func page(page: Int, per limit: Int) -> Self {
.page(.init(page: page, per: limit)) .page(.init(page: page, per: limit))
@@ -80,19 +85,6 @@ extension SiteRoute.View {
} }
DetailRoute.router DetailRoute.router
} }
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Optionally {
Field("id", default: nil) { Project.ID.parser() }
}
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.index)) { Route(.case(Self.index)) {
Path { rootPath } Path { rootPath }
Method.get Method.get
@@ -110,11 +102,13 @@ extension SiteRoute.View {
.map(.memberwise(PageRequest.init)) .map(.memberwise(PageRequest.init))
} }
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { rootPath } Path {
rootPath
Project.ID.parser()
}
Method.patch Method.patch
Body { Body {
FormData { FormData {
Field("id") { Project.ID.parser() }
Optionally { Optionally {
Field("name", .string) Field("name", .string)
} }
@@ -146,23 +140,30 @@ extension SiteRoute.View {
extension SiteRoute.View.ProjectRoute { extension SiteRoute.View.ProjectRoute {
public enum DetailRoute: Equatable, Sendable { public enum DetailRoute: Equatable, Sendable {
case index(tab: Tab = .default) case index
case componentLoss(ComponentLossRoute)
case ductSizing(DuctSizingRoute)
case equipment(EquipmentInfoRoute) case equipment(EquipmentInfoRoute)
case equivalentLength(EquivalentLengthRoute)
case frictionRate(FrictionRateRoute) case frictionRate(FrictionRateRoute)
case rooms(RoomRoute) case rooms(RoomRoute)
static let router = OneOf { static let router = OneOf {
Route(.case(Self.index)) { Route(.case(Self.index)) {
Method.get Method.get
Query { }
Field("tab", default: Tab.default) { Route(.case(Self.componentLoss)) {
Tab.parser() ComponentLossRoute.router
} }
} Route(.case(Self.ductSizing)) {
DuctSizingRoute.router
} }
Route(.case(Self.equipment)) { Route(.case(Self.equipment)) {
EquipmentInfoRoute.router EquipmentInfoRoute.router
} }
Route(.case(Self.equivalentLength)) {
EquivalentLengthRoute.router
}
Route(.case(Self.frictionRate)) { Route(.case(Self.frictionRate)) {
FrictionRateRoute.router FrictionRateRoute.router
} }
@@ -173,21 +174,19 @@ extension SiteRoute.View.ProjectRoute {
public enum Tab: String, CaseIterable, Equatable, Sendable { public enum Tab: String, CaseIterable, Equatable, Sendable {
case project case project
case equipment
case rooms case rooms
case effectiveLength case equivalentLength
case frictionRate case frictionRate
case ductSizing case ductSizing
public static var `default`: Self { .rooms }
} }
} }
public enum RoomRoute: Equatable, Sendable { public enum RoomRoute: Equatable, Sendable {
case delete(id: Room.ID) case delete(id: Room.ID)
case form(id: Room.ID? = nil, dismiss: Bool = false)
case index case index
case submit(Room.Create) case submit(Room.Create)
case update(Room.Update) case update(Room.ID, Room.Update)
case updateSensibleHeatRatio(SHRUpdate) case updateSensibleHeatRatio(SHRUpdate)
static let rootPath = "rooms" static let rootPath = "rooms"
@@ -200,19 +199,6 @@ extension SiteRoute.View.ProjectRoute {
} }
Method.delete Method.delete
} }
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Optionally {
Field("id", default: nil) { Room.ID.parser() }
}
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.index)) { Route(.case(Self.index)) {
Path { Path {
rootPath rootPath
@@ -237,11 +223,13 @@ extension SiteRoute.View.ProjectRoute {
} }
} }
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { rootPath } Path {
rootPath
Room.ID.parser()
}
Method.patch Method.patch
Body { Body {
FormData { FormData {
Field("id") { Room.ID.parser() }
Optionally { Optionally {
Field("name", .string) Field("name", .string)
} }
@@ -285,10 +273,61 @@ extension SiteRoute.View.ProjectRoute {
} }
} }
public enum ComponentLossRoute: Equatable, Sendable {
case index
case delete(ComponentPressureLoss.ID)
case submit(ComponentPressureLoss.Create)
case update(ComponentPressureLoss.ID, ComponentPressureLoss.Update)
static let rootPath = "component-loss"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.delete)) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("name", .string)
Field("value") { Double.parser() }
}
.map(.memberwise(ComponentPressureLoss.Create.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
ComponentPressureLoss.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("name", .string)
}
Optionally {
Field("value") { Double.parser() }
}
}
.map(.memberwise(ComponentPressureLoss.Update.init))
}
}
}
}
public enum FrictionRateRoute: Equatable, Sendable { public enum FrictionRateRoute: Equatable, Sendable {
case index case index
// TODO: Remove form or move equipment / component losses routes here.
case form(FormType, dismiss: Bool = false)
static let rootPath = "friction-rate" static let rootPath = "friction-rate"
@@ -297,30 +336,13 @@ extension SiteRoute.View.ProjectRoute {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("type") { FormType.parser() }
Field("dismiss", default: false) { Bool.parser() }
}
}
}
public enum FormType: String, CaseIterable, Codable, Equatable, Sendable {
case equipmentInfo
case componentPressureLoss
} }
} }
public enum EquipmentInfoRoute: Equatable, Sendable { public enum EquipmentInfoRoute: Equatable, Sendable {
case index case index
case form(dismiss: Bool)
case submit(EquipmentInfo.Create) case submit(EquipmentInfo.Create)
case update(EquipmentInfo.Update) case update(EquipmentInfo.ID, EquipmentInfo.Update)
static let rootPath = "equipment" static let rootPath = "equipment"
@@ -329,16 +351,6 @@ extension SiteRoute.View.ProjectRoute {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.form)) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("dismiss", default: true) { Bool.parser() }
}
}
Route(.case(Self.submit)) { Route(.case(Self.submit)) {
Path { rootPath } Path { rootPath }
Method.post Method.post
@@ -353,11 +365,13 @@ extension SiteRoute.View.ProjectRoute {
} }
} }
Route(.case(Self.update)) { Route(.case(Self.update)) {
Path { rootPath } Path {
rootPath
EquipmentInfo.ID.parser()
}
Method.patch Method.patch
Body { Body {
FormData { FormData {
Field("id") { EquipmentInfo.ID.parser() }
Optionally { Optionally {
Field("staticPressure", default: nil) { Double.parser() } Field("staticPressure", default: nil) { Double.parser() }
} }
@@ -373,31 +387,28 @@ extension SiteRoute.View.ProjectRoute {
} }
} }
} }
}
extension SiteRoute.View { public enum EquivalentLengthRoute: Equatable, Sendable {
public enum EffectiveLengthRoute: Equatable, Sendable { case delete(id: EffectiveLength.ID)
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil) case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
case form(dismiss: Bool = false)
case index case index
case submit(FormStep)
case update(EffectiveLength.ID, StepThree)
static let rootPath = "effective-lengths" static let rootPath = "effective-lengths"
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.delete(id:))) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.delete
}
Route(.case(Self.index)) { Route(.case(Self.index)) {
Path { rootPath } Path { rootPath }
Method.get Method.get
} }
Route(.case(Self.form(dismiss:))) {
Path {
rootPath
"create"
}
Method.get
Query {
Field("dismiss", default: false) { Bool.parser() }
}
}
Route(.case(Self.field)) { Route(.case(Self.field)) {
Path { Path {
rootPath rootPath
@@ -413,20 +424,326 @@ extension SiteRoute.View {
} }
} }
} }
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
FormStep.router
}
Route(.case(Self.update)) {
Path {
rootPath
EffectiveLength.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
Many {
Field("group[group]") {
Int.parser()
}
}
Many {
Field("group[letter]", .string)
}
Many {
Field("group[length]") {
Int.parser()
}
}
Many {
Field("group[quantity]") {
Int.parser()
}
}
}
.map(.memberwise(StepThree.init))
}
}
}
public enum FormStep: Equatable, Sendable {
case one(StepOne)
case two(StepTwo)
case three(StepThree)
static let router = OneOf {
Route(.case(Self.one)) {
Path {
Key.stepOne.rawValue
}
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
}
.map(.memberwise(StepOne.init))
}
}
Route(.case(Self.two)) {
Path {
Key.stepTwo.rawValue
}
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
}
.map(.memberwise(StepTwo.init))
}
}
Route(.case(Self.three)) {
Path {
Key.stepThree.rawValue
}
Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
Many {
Field("group[group]") {
Int.parser()
}
}
Many {
Field("group[letter]", .string)
}
Many {
Field("group[length]") {
Int.parser()
}
}
Many {
Field("group[quantity]") {
Int.parser()
}
}
}
.map(.memberwise(StepThree.init))
}
}
}
public enum Key: String, CaseIterable, Codable, Equatable, Sendable {
case stepOne
case stepTwo
case stepThree
}
}
public struct StepOne: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
}
public struct StepTwo: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let straightLengths: [Int]
public init(
id: EffectiveLength.ID? = nil,
name: String,
type: EffectiveLength.EffectiveLengthType,
straightLengths: [Int]
) {
self.id = id
self.name = name
self.type = type
self.straightLengths = straightLengths
}
}
public struct StepThree: Codable, Equatable, Sendable {
public let id: EffectiveLength.ID?
public let name: String
public let type: EffectiveLength.EffectiveLengthType
public let straightLengths: [Int]
public let groupGroups: [Int]
public let groupLetters: [String]
public let groupLengths: [Int]
public let groupQuantities: [Int]
}
public enum FieldType: String, CaseIterable, Equatable, Sendable {
case straightLength
case group
} }
}
}
extension SiteRoute.View.EffectiveLengthRoute {
public enum FieldType: String, CaseIterable, Equatable, Sendable {
case straightLength
case group
} }
public enum FormStep: String, CaseIterable, Equatable, Sendable { public enum DuctSizingRoute: Equatable, Sendable {
case nameAndType case index
case straightLengths case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
case groups case roomRectangularForm(Room.ID, RoomRectangularForm)
case trunk(TrunkRoute)
public static let roomPath = "room"
static let rootPath = "duct-sizing"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.deleteRectangularSize)) {
Path {
rootPath
roomPath
Room.ID.parser()
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
}
Route(.case(Self.roomRectangularForm)) {
Path {
rootPath
roomPath
Room.ID.parser()
}
Method.post
Body {
FormData {
Optionally {
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
}
Field("register") { Int.parser() }
Field("height") { Int.parser() }
}
.map(.memberwise(RoomRectangularForm.init))
}
}
Route(.case(Self.trunk)) {
Path { rootPath }
TrunkRoute.router
}
}
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
public let register: Int
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID)
case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
static let router = OneOf {
Route(.case(Self.delete)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path {
rootPath
}
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
}
}
public struct RoomRectangularForm: Equatable, Sendable {
public let id: DuctSizing.RectangularDuct.ID?
public let register: Int
public let height: Int
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: DuctSizing.TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
}
} }
} }
@@ -474,6 +791,7 @@ extension SiteRoute.View {
public enum SignupRoute: Equatable, Sendable { public enum SignupRoute: Equatable, Sendable {
case index case index
case submit(User.Create) case submit(User.Create)
case submitProfile(User.Profile.Create)
static let rootPath = "signup" static let rootPath = "signup"
@@ -487,7 +805,6 @@ extension SiteRoute.View {
Method.post Method.post
Body { Body {
FormData { FormData {
Field("username", .string)
Field("email", .string) Field("email", .string)
Field("password", .string) Field("password", .string)
Field("confirmPassword", .string) Field("confirmPassword", .string)
@@ -495,6 +812,114 @@ extension SiteRoute.View {
.map(.memberwise(User.Create.init)) .map(.memberwise(User.Create.init))
} }
} }
Route(.case(Self.submitProfile)) {
Path {
rootPath
"profile"
}
Method.post
Body {
FormData {
Field("userID") { User.ID.parser() }
Field("firstName", .string)
Field("lastName", .string)
Field("companyName", .string)
Field("streetAddress", .string)
Field("city", .string)
Field("state", .string)
Field("zipCode", .string)
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Create.init))
}
}
}
}
}
extension SiteRoute.View {
public enum UserRoute: Equatable, Sendable {
case profile(Profile)
static let router = OneOf {
Route(.case(Self.profile)) {
Profile.router
}
}
}
}
extension SiteRoute.View.UserRoute {
public enum Profile: Equatable, Sendable {
case index
case submit(User.Profile.Create)
case update(User.Profile.ID, User.Profile.Update)
static let rootPath = "profile"
static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("userID") { User.ID.parser() }
Field("firstName", .string)
Field("lastName", .string)
Field("companyName", .string)
Field("streetAddress", .string)
Field("city", .string)
Field("state", .string)
Field("zipCode", .string)
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Create.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
User.Profile.ID.parser()
}
Method.patch
Body {
FormData {
Optionally {
Field("firstName", .string)
}
Optionally {
Field("lastName", .string)
}
Optionally {
Field("companyName", .string)
}
Optionally {
Field("streetAddress", .string)
}
Optionally {
Field("city", .string)
}
Optionally {
Field("state", .string)
}
Optionally {
Field("zipCode", .string)
}
Optionally {
Field("theme") { Theme.parser() }
}
}
.map(.memberwise(User.Profile.Update.init))
}
}
} }
} }
} }

View File

@@ -0,0 +1,31 @@
import Foundation
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua
case cupcake
case cyberpunk
case dark
case `default`
case dracula
case light
case night
case nord
case retro
case synthwave
public static let darkThemes = [
Self.aqua,
Self.cyberpunk,
Self.dark,
Self.dracula,
Self.night,
Self.synthwave,
]
public static let lightThemes = [
Self.cupcake,
Self.light,
Self.nord,
Self.retro,
]
}

View File

@@ -1,23 +1,21 @@
import Dependencies import Dependencies
import Foundation import Foundation
// FIX: Remove username.
public struct User: Codable, Equatable, Identifiable, Sendable { public struct User: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID
public let email: String public let email: String
public let username: String
public let createdAt: Date public let createdAt: Date
public let updatedAt: Date public let updatedAt: Date
public init( public init(
id: UUID, id: UUID,
email: String, email: String,
username: String,
createdAt: Date, createdAt: Date,
updatedAt: Date updatedAt: Date
) { ) {
self.id = id self.id = id
self.username = username
self.email = email self.email = email
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
@@ -27,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
extension User { extension User {
public struct Create: Codable, Equatable, Sendable { public struct Create: Codable, Equatable, Sendable {
public let username: String
public let email: String public let email: String
public let password: String public let password: String
public let confirmPassword: String public let confirmPassword: String
public init( public init(
username: String,
email: String, email: String,
password: String, password: String,
confirmPassword: String confirmPassword: String
) { ) {
self.username = username
self.email = email self.email = email
self.password = password self.password = password
self.confirmPassword = confirmPassword self.confirmPassword = confirmPassword

View File

@@ -0,0 +1,115 @@
import Foundation
extension User {
public struct Profile: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let userID: User.ID
public let firstName: String
public let lastName: String
public let companyName: String
public let streetAddress: String
public let city: String
public let state: String
public let zipCode: String
public let theme: Theme?
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
}
extension User.Profile {
public struct Create: Codable, Equatable, Sendable {
public let userID: User.ID
public let firstName: String
public let lastName: String
public let companyName: String
public let streetAddress: String
public let city: String
public let state: String
public let zipCode: String
public let theme: Theme?
public init(
userID: User.ID,
firstName: String,
lastName: String,
companyName: String,
streetAddress: String,
city: String,
state: String,
zipCode: String,
theme: Theme? = nil
) {
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
public struct Update: Codable, Equatable, Sendable {
public let firstName: String?
public let lastName: String?
public let companyName: String?
public let streetAddress: String?
public let city: String?
public let state: String?
public let zipCode: String?
public let theme: Theme?
public init(
firstName: String? = nil,
lastName: String? = nil,
companyName: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
theme: Theme? = nil
) {
self.firstName = firstName
self.lastName = lastName
self.companyName = companyName
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.theme = theme
}
}
}

View File

@@ -0,0 +1,28 @@
import Elementary
public struct Alert<Content: HTML>: HTML {
let inner: Content
public init(@HTMLBuilder content: () -> Content) {
self.inner = content()
}
public var body: some HTML<HTMLTag.div> {
div(.class("flex space-x-2")) {
SVG(.triangleAlert)
inner
}
}
}
extension Alert: Sendable where Content: Sendable {}
extension Alert where Content == p<HTMLText> {
public init(_ description: String) {
self.init {
p { description }
}
}
}

View File

@@ -0,0 +1,28 @@
import Elementary
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
let inner: Inner
public init(
@HTMLBuilder inner: () -> Inner
) {
self.inner = inner()
}
public var body: some HTML<HTMLTag.div> {
div(.class("badge badge-lg badge-outline")) {
inner
}
}
}
extension Badge where Inner == Number {
public init(number: Int) {
self.inner = Number(number)
}
public init(number: Double, digits: Int = 2) {
self.inner = Number(number, digits: digits)
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import Elementary import Elementary
import Foundation
import ManualDCore import ManualDCore
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href { extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
@@ -28,6 +29,10 @@ extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ double: Double?) -> Self { public static func value(_ double: Double?) -> Self {
value(double == nil ? "" : "\(double!)") value(double == nil ? "" : "\(double!)")
} }
public static func value(_ uuid: UUID?) -> Self {
value(uuid?.uuidString ?? "")
}
} }
extension HTMLAttribute where Tag == HTMLTag.button { extension HTMLAttribute where Tag == HTMLTag.button {
@@ -35,3 +40,17 @@ extension HTMLAttribute where Tag == HTMLTag.button {
.on(.click, "\(id).showModal()") .on(.click, "\(id).showModal()")
} }
} }
extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline"))
}
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
attributes(.class("hidden"), when: shouldHide)
}
public func bold(when shouldBeBold: Bool = true) -> _AttributedElement<Self> {
attributes(.class("font-bold"), when: shouldBeBold)
}
}

View File

@@ -1,5 +1,6 @@
import Elementary import Elementary
// TODO: Remove, using svg's.
public struct Icon: HTML, Sendable { public struct Icon: HTML, Sendable {
let icon: String let icon: String

View File

@@ -1,5 +1,26 @@
import Elementary import Elementary
public struct LabeledInput: HTML, Sendable {
let labelText: String
let inputAttributes: [HTMLAttribute<HTMLTag.input>]
public init(
_ label: String,
_ attributes: HTMLAttribute<HTMLTag.input>...
) {
self.labelText = label
self.inputAttributes = attributes
}
public var body: some HTML<HTMLTag.label> {
label(.class("input w-full")) {
span(.class("label")) { labelText }
input(attributes: inputAttributes)
}
}
}
public struct Input: HTML, Sendable { public struct Input: HTML, Sendable {
let id: String? let id: String?

View File

@@ -13,7 +13,7 @@ public struct Label: HTML, Sendable {
} }
public var body: some HTML<HTMLTag.span> { public var body: some HTML<HTMLTag.span> {
span(.class("text-xl text-gray-400 font-bold")) { span(.class("text-lg label font-bold")) {
title title
} }
} }

View File

@@ -0,0 +1,72 @@
import Elementary
public struct LabeledContent<Label: HTML, Content: HTML>: HTML {
let label: @Sendable () -> Label
let content: @Sendable () -> Content
let position: LabelPosition
public init(
position: LabelPosition = .default,
@HTMLBuilder label: @escaping @Sendable () -> Label,
@HTMLBuilder content: @escaping @Sendable () -> Content
) {
self.position = position
self.label = label
self.content = content
}
public var body: some HTML<HTMLTag.div> {
div {
switch position {
case .leading:
label()
content()
case .trailing:
content()
label()
case .top:
label()
content()
case .bottom:
content()
label()
}
}
.attributes(.class("flex space-x-4"), when: position.isHorizontal)
.attributes(.class("space-y-4"), when: position.isVertical)
}
}
// TODO: Merge / use TooltipPosition
public enum LabelPosition: String, CaseIterable, Equatable, Sendable {
case leading
case trailing
case top
case bottom
var isHorizontal: Bool {
self == .leading || self == .trailing
}
var isVertical: Bool {
self == .top || self == .bottom
}
public static let `default` = Self.leading
}
extension LabeledContent: Sendable where Label: Sendable, Content: Sendable {}
extension LabeledContent where Label == Styleguide.Label {
public init(
_ label: String, position: LabelPosition = .default,
@HTMLBuilder content: @escaping @Sendable () -> Content
) {
self.init(
position: position,
label: { Label(label) },
content: content
)
}
}

View File

@@ -19,7 +19,7 @@ public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
self.inner = inner() self.inner = inner()
} }
public var body: some HTML { public var body: some HTML<HTMLTag.dialog> {
dialog(.id(id), .class("modal")) { dialog(.id(id), .class("modal")) {
div(.class("modal-box")) { div(.class("modal-box")) {
if closeButton { if closeButton {

View File

@@ -0,0 +1,40 @@
import Elementary
public struct PageTitleRow<Content: HTML>: HTML, Sendable where Content: Sendable {
let inner: Content
public init(@HTMLBuilder content: () -> Content) {
self.inner = content()
}
public var body: some HTML<HTMLTag.div> {
div(
.class(
"""
flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
p-6 w-full
"""
)
) {
inner
}
}
}
public struct PageTitle: HTML, Sendable {
let title: String
public init(_ title: String) {
self.title = title
}
public init(_ title: () -> String) {
self.title = title()
}
public var body: some HTML<HTMLTag.h1> {
h1(.class("text-3xl font-bold")) { title }
}
}

View File

@@ -0,0 +1,89 @@
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)
}
}
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),
onSuccess: { EmptyHTML() }
) { 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

@@ -15,24 +15,66 @@ public struct SVG: HTML, Sendable {
extension SVG { extension SVG {
public enum Key: Sendable { public enum Key: Sendable {
case badgeCheck
case ban
case chevronDown
case chevronRight
case chevronsLeft
case circlePlus case circlePlus
case circleUser
case close case close
case doorClosed
case email case email
case fan
case key case key
case mapPin
case rulerDimensionLine
case sidebarToggle
case squareFunction
case squarePen case squarePen
case trash case trash
case triangleAlert
case user case user
case wind
var svg: String { var svg: String {
switch self { switch self {
case .badgeCheck:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>
"""
case .ban:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
"""
case .chevronDown:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
"""
case .chevronRight:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
"""
case .chevronsLeft:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>
"""
case .circlePlus: case .circlePlus:
return """ return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
""" """
case .circleUser:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
"""
case .close: case .close:
return """ return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
""" """
case .doorClosed:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg>
"""
case .email: case .email:
return """ return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -48,6 +90,10 @@ extension SVG {
</g> </g>
</svg> </svg>
""" """
case .fan:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg>
"""
case .key: case .key:
return """ return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -65,6 +111,22 @@ extension SVG {
</g> </g>
</svg> </svg>
""" """
case .mapPin:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
"""
case .rulerDimensionLine:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg>
"""
case .sidebarToggle:
return """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
"""
case .squareFunction:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg>
"""
case .squarePen: case .squarePen:
return """ return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
@@ -74,6 +136,10 @@ extension SVG {
return """ return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
""" """
case .triangleAlert:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
"""
case .user: case .user:
return """ return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -89,6 +155,10 @@ extension SVG {
</g> </g>
</svg> </svg>
""" """
case .wind:
return """
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>
"""
} }
} }
} }

View File

@@ -0,0 +1,51 @@
import Elementary
extension HTML {
public func tooltip(
_ tip: String,
position: TooltipPosition = .default
) -> Tooltip<Self> {
Tooltip(tip, position: position) {
self
}
}
}
public struct Tooltip<Inner: HTML>: HTML {
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 tooltip-\(position.rawValue)"),
.data("tip", value: tooltip)
) {
inner
}
}
}
extension Tooltip: Sendable where Inner: Sendable {}
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 DatabaseClient
import Dependencies
import Fluent import Fluent
import ManualDClient
import ManualDCore import ManualDCore
import Vapor import Vapor
@@ -22,6 +24,43 @@ extension DatabaseClient.Projects {
} }
} }
extension DatabaseClient {
func calculateDuctSizes(
projectID: Project.ID
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
@Dependency(\.manualD) var manualD
return try await manualD.calculate(
rooms: rooms.fetch(projectID),
trunks: trunkSizes.fetch(projectID),
designFrictionRateResult: designFrictionRate(projectID: projectID),
projectSHR: projects.getSensibleHeatRatio(projectID)
)
}
func designFrictionRate(
projectID: Project.ID
) async throws -> (EquipmentInfo, EffectiveLength.MaxContainer, Double)? {
guard let equipmentInfo = try await equipment.fetch(projectID) 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 designFrictionRate = (availableStaticPressure * 100) / tel
return (equipmentInfo, equivalentLengths, designFrictionRate)
}
}
extension DatabaseClient.ComponentLoss { extension DatabaseClient.ComponentLoss {
func createDefaults(projectID: Project.ID) async throws { func createDefaults(projectID: Project.ID) async throws {

View File

@@ -0,0 +1,60 @@
import DatabaseClient
import ManualDCore
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
func validate() throws(ValidationError) {
guard groupGroups.count == groupLengths.count,
groupGroups.count == groupLetters.count,
groupGroups.count == groupQuantities.count
else {
throw ValidationError("Equivalent length form group counts are not equal.")
}
}
var groups: [EffectiveLength.Group] {
var groups = [EffectiveLength.Group]()
for (n, group) in groupGroups.enumerated() {
groups.append(
.init(
group: group,
letter: groupLetters[n],
value: Double(groupLengths[n]),
quantity: groupQuantities[n]
)
)
}
return groups
}
}
extension EffectiveLength.Create {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID
) {
self.init(
projectID: projectID,
name: form.name,
type: form.type,
straightLengths: form.straightLengths,
groups: form.groups
)
}
}
extension EffectiveLength.Update {
init(
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
projectID: Project.ID
) throws {
self.init(
name: form.name,
type: form.type,
straightLengths: form.straightLengths,
groups: form.groups
)
}
}

View File

@@ -0,0 +1,38 @@
import Logging
import ManualDClient
import ManualDCore
extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
guard let designFrictionRateResult else { return ([], []) }
let equipmentInfo = designFrictionRateResult.0
let effectiveLengths = designFrictionRateResult.1
let designFrictionRate = designFrictionRateResult.2
guard let maxSupply = effectiveLengths.supply else { return ([], []) }
guard let maxReturn = effectiveLengths.return else { return ([], []) }
let ductRooms = try await self.calculateSizes(
rooms: rooms,
trunks: trunks,
equipmentInfo: equipmentInfo,
maxSupplyLength: maxSupply,
maxReturnLength: maxReturn,
designFrictionRate: designFrictionRate,
projectSHR: projectSHR ?? 1.0,
logger: logger
)
// logger?.debug("Rooms: \(ductRooms)")
return ductRooms
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
extension String {
func appendingPath(_ string: String) -> Self {
guard string.starts(with: "/") else {
return self.appending("/\(string)")
}
return self.appending(string)
}
func appendingPath(_ id: UUID?) -> Self {
guard let id else { return self }
return appendingPath(id.uuidString)
}
func appendingPath(_ id: UUID) -> Self {
return appendingPath(id.uuidString)
}
var idString: Self {
replacing("-", with: "")
.replacing(" ", with: "")
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import Logging
import ManualDCore
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create {
try .init(
projectID: projectID,
type: type,
rooms: makeRooms(logger: logger),
height: height,
name: name
)
}
func toUpdate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Update {
try .init(
type: type,
rooms: makeRooms(logger: logger),
height: height,
name: name
)
}
func makeRooms(logger: Logger?) throws -> [Room.ID: [Int]] {
var retval = [Room.ID: [Int]]()
for room in rooms {
let split = room.split(separator: "_")
guard let idString = split.first,
let id = UUID(uuidString: String(idString))
else {
logger?.error("Could not parse id from: \(room)")
throw RoomError()
}
guard let registerString = split.last,
let register = Int(registerString)
else {
logger?.error("Could not register number from: \(room)")
throw RoomError()
}
if var currRegisters = retval[id] {
currRegisters.append(register)
retval[id] = currRegisters
} else {
retval[id] = [register]
}
}
return retval
}
}
struct RoomError: Error {}

View File

@@ -0,0 +1,7 @@
import Foundation
extension UUID {
var idString: String {
uuidString.idString
}
}

View File

@@ -55,7 +55,7 @@ extension ViewController: DependencyKey {
// FIX: Fix. // FIX: Fix.
public static let liveValue = Self( public static let liveValue = Self(
view: { request in view: { request in
try await request.render() await request.render()
} }
) )
} }
@@ -73,6 +73,7 @@ extension ViewController.Request {
return user return user
} }
@discardableResult
func createAndAuthenticate( func createAndAuthenticate(
_ signup: User.Create _ signup: User.Create
) async throws -> User { ) async throws -> User {

View File

@@ -3,65 +3,105 @@ import Dependencies
import Elementary import Elementary
import Foundation import Foundation
import ManualDCore import ManualDCore
import Styleguide
extension ViewController.Request { extension ViewController.Request {
func render() async throws -> AnySendableHTML { func render() async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
switch route { switch route {
case .test:
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
return await view {
await ResultView {
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (_, result) in
TestPage(trunks: result.trunks, rooms: result.rooms)
}
}
case .login(let route): case .login(let route):
switch route { switch route {
case .index(let next): case .index(let next):
return view { return await view {
LoginForm(next: next) LoginForm(next: next)
} }
case .submit(let login): case .submit(let login):
let _ = try await authenticate(login) // let _ = try await authenticate(login)
return view { return await view {
LoggedIn(next: login.next) await ResultView {
try await authenticate(login)
} onSuccess: { _ in
LoggedIn(next: login.next)
}
} }
} }
case .signup(let route): case .signup(let route):
switch route { switch route {
case .index: case .index:
return view { return await view {
LoginForm(style: .signup) LoginForm(style: .signup)
} }
case .submit(let request): case .submit(let request):
// Create a new user and log them in. // Create a new user and log them in.
let user = try await createAndAuthenticate(request) return await view {
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25)) await ResultView {
return view { try await createAndAuthenticate(request)
ProjectsTable(userID: user.id, projects: projects) } onSuccess: { user in
MainPage {
UserProfileForm(userID: user.id, profile: nil, dismiss: false, signup: true)
}
}
}
case .submitProfile(let profile):
return await view {
await ResultView {
_ = try await database.userProfile.create(profile)
let userID = profile.userID
// let user = try currentUser()
return (
userID,
try await database.projects.fetch(userID, .init(page: 1, per: 25))
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
}
} }
} }
case .project(let route): case .project(let route):
return try await route.renderView(on: self) return await route.renderView(on: self)
case .effectiveLength(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) case .user(let route):
// case .user(let route): return await route.renderView(on: self)
// return try await route.renderView(isHtmxRequest: isHtmxRequest)
default:
// FIX: FIX
return _render(isHtmxRequest: false) {
div { "Fix me!" }
}
} }
} }
func view<C: HTML>( func view<C: HTML>(
@HTMLBuilder inner: () -> C @HTMLBuilder inner: () async -> C
) -> AnySendableHTML where C: Sendable { ) async -> AnySendableHTML where C: Sendable {
_render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) { let inner = await inner()
inner() let theme = await self.theme
return MainPage(displayFooter: displayFooter, theme: theme) {
inner
} }
} }
var showSidebar: Bool { var theme: Theme? {
get async {
@Dependency(\.database) var database
guard let user = try? currentUser() else { return nil }
return try? await database.userProfile.fetch(user.id)?.theme
}
}
var displayFooter: Bool {
switch route { switch route {
case .login, .signup, .project(.page): case .login, .signup:
return false return false
default: default:
return true return true
@@ -71,83 +111,155 @@ extension ViewController.Request {
extension SiteRoute.View.ProjectRoute { extension SiteRoute.View.ProjectRoute {
func renderView(on request: ViewController.Request) async throws -> AnySendableHTML { func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
let user = try request.currentUser()
switch self { switch self {
case .index: case .index:
let projects = try await database.projects.fetchPage(userID: user.id) return await request.view {
return request.view { await ResultView {
ProjectsTable(userID: user.id, projects: projects) let user = try request.currentUser()
return try await (
user.id,
database.projects.fetchPage(userID: user.id)
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
}
} }
case .page(let page): case .page(let page):
let projects = try await database.projects.fetch(user.id, page) return await ResultView {
return ProjectsTable(userID: user.id, projects: projects) let user = try request.currentUser()
return try await (
case .form(let id, let dismiss): user.id,
request.logger.debug("Project form: \(id != nil ? "Fetching project for: \(id!)" : "N/A")") database.projects.fetch(user.id, page)
var project: Project? = nil )
if let id, dismiss == false { } onSuccess: { (userID, projects) in
project = try await database.projects.get(id) ProjectsTable(userID: userID, projects: projects)
} }
request.logger.debug(
project == nil ? "No project found" : "Showing form for existing project"
)
return ProjectForm(dismiss: dismiss, project: project)
case .create(let form): case .create(let form):
let project = try await database.projects.create(user.id, form) return await request.view {
try await database.componentLoss.createDefaults(projectID: project.id) await ResultView {
return ProjectView(projectID: project.id, activeTab: .rooms) let user = try request.currentUser()
let project = try await database.projects.create(user.id, form)
try await database.componentLoss.createDefaults(projectID: project.id)
let rooms = try await database.rooms.fetch(project.id)
let shr = try await database.projects.getSensibleHeatRatio(project.id)
let completedSteps = try await database.projects.getCompletedSteps(project.id)
return (project.id, rooms, shr, completedSteps)
} onSuccess: { (projectID, rooms, shr, completedSteps) in
ProjectView(
projectID: projectID,
activeTab: .rooms,
completedSteps: completedSteps
) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
}
}
}
case .delete(let id): case .delete(let id):
try await database.projects.delete(id) return await ResultView {
return EmptyHTML() try await database.projects.delete(id)
}
case .update(let form): case .update(let id, let form):
let project = try await database.projects.update(form) return await projectView(on: request, projectID: id) {
return ProjectView(projectID: project.id, activeTab: .project) _ = try await database.projects.update(id, form)
}
case .detail(let projectID, let route): case .detail(let projectID, let route):
switch route { switch route {
case .index(let tab): case .index:
return request.view { return await projectView(on: request, projectID: projectID)
ProjectView(projectID: projectID, activeTab: tab) case .componentLoss(let route):
} return await route.renderView(on: request, projectID: projectID)
case .ductSizing(let route):
return await route.renderView(on: request, projectID: projectID)
case .equipment(let route): case .equipment(let route):
return try await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
case .equivalentLength(let route):
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route): case .frictionRate(let route):
return try await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
case .rooms(let route): case .rooms(let route):
return try await route.renderView(on: request, projectID: projectID) return await route.renderView(on: request, projectID: projectID)
} }
} }
} }
func projectView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
guard let project = try await database.projects.get(projectID) else {
throw NotFoundError()
}
return (
try await database.projects.getCompletedSteps(project.id),
project
)
} onSuccess: { (steps, project) in
ProjectView(projectID: projectID, activeTab: .project, completedSteps: steps) {
ProjectDetail(project: project)
}
}
}
}
} }
extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute { extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
func renderView( func renderView(
on request: ViewController.Request, on request: ViewController.Request,
projectID: Project.ID projectID: Project.ID
) async throws -> AnySendableHTML { ) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
switch self { switch self {
case .index: case .index:
let equipment = try await database.equipment.fetch(projectID) return await equipmentView(on: request, projectID: projectID)
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
case .form(let dismiss):
let equipment = try await database.equipment.fetch(projectID)
return EquipmentInfoForm(dismiss: dismiss, projectID: projectID, equipmentInfo: equipment)
case .submit(let form): case .submit(let form):
let equipment = try await database.equipment.create(form) return await equipmentView(on: request, projectID: projectID) {
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID) _ = try await database.equipment.create(form)
case .update(let updates): }
let equipment = try await database.equipment.update(updates)
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID) case .update(let id, let updates):
return await equipmentView(on: request, projectID: projectID) {
_ = try await database.equipment.update(id, updates)
}
}
}
func equipmentView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.equipment.fetch(projectID)
)
} onSuccess: { (steps, equipment) in
ProjectView(projectID: projectID, activeTab: .equipment, completedSteps: steps) {
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
}
}
} }
} }
} }
@@ -156,98 +268,203 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
func renderView( func renderView(
on request: ViewController.Request, on request: ViewController.Request,
projectID: Project.ID projectID: Project.ID
) async throws -> AnySendableHTML { ) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
switch self { switch self {
case .delete(let id): case .delete(let id):
try await database.rooms.delete(id) return await ResultView {
return EmptyHTML() try await database.rooms.delete(id)
case .form(let id, let dismiss):
var room: Room? = nil
if let id, dismiss == false {
room = try await database.rooms.get(id)
} }
return RoomForm(dismiss: dismiss, projectID: projectID, room: room)
case .index: case .index:
return request.view { return await roomsView(on: request, projectID: projectID)
ProjectView(projectID: projectID, activeTab: .rooms)
}
case .submit(let form): case .submit(let form):
request.logger.debug("New room form submitted.") return await roomsView(on: request, projectID: projectID) {
let _ = try await database.rooms.create(form) _ = try await database.rooms.create(form)
return request.view {
ProjectView(projectID: projectID, activeTab: .rooms)
} }
case .update(let form): case .update(let id, let form):
_ = try await database.rooms.update(form) return await roomsView(on: request, projectID: projectID) {
return ProjectView(projectID: projectID, activeTab: .rooms) _ = try await database.rooms.update(id, form)
}
case .updateSensibleHeatRatio(let form): case .updateSensibleHeatRatio(let form):
let _ = try await database.projects.update( return await roomsView(on: request, projectID: projectID) {
.init(id: form.projectID, sensibleHeatRatio: form.sensibleHeatRatio) _ = try await database.projects.update(
) form.projectID,
return request.view { .init(sensibleHeatRatio: form.sensibleHeatRatio)
ProjectView(projectID: projectID, activeTab: .rooms) )
}
}
}
func roomsView(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.rooms.fetch(projectID),
try await database.projects.getSensibleHeatRatio(projectID)
)
} onSuccess: { (steps, rooms, shr) in
ProjectView(projectID: projectID, activeTab: .rooms, completedSteps: steps) {
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
}
} }
} }
} }
} }
extension SiteRoute.View.ProjectRoute.FrictionRateRoute { extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
func renderView(on request: ViewController.Request, projectID: Project.ID) async throws func renderView(
-> AnySendableHTML on request: ViewController.Request,
{ projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
switch self {
case .index:
return await view(on: request, projectID: projectID)
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
return await request.view {
await ResultView {
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
)
}
}
}
}
}
extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database @Dependency(\.database) var database
switch self { switch self {
case .index: case .index:
let equipment = try await database.equipment.fetch(projectID) return EmptyHTML()
let componentLosses = try await database.componentLoss.fetch(projectID) case .delete(let id):
return await view(on: request, projectID: projectID) {
return request.view { _ = try await database.componentLoss.delete(id)
ProjectView(projectID: projectID, activeTab: .frictionRate) }
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form)
} }
case .form(let type, let dismiss): case .update(let id, let form):
// FIX: Forms need to reference existing items. return await view(on: request, projectID: projectID) {
switch type { _ = try await database.componentLoss.update(id, form)
case .equipmentInfo:
return div { "REMOVE ME!" }
// return EquipmentForm(dismiss: dismiss, projectID: projectID)
case .componentPressureLoss:
return ComponentLossForm(dismiss: dismiss, projectID: projectID)
} }
} }
} }
}
extension SiteRoute.View.ProjectRoute.FrictionRateRoute.FormType { func view(
var id: String { on request: ViewController.Request,
switch self { projectID: Project.ID,
case .equipmentInfo: catching: @escaping @Sendable () async throws -> Void = {}
return "equipmentForm" ) async -> AnySendableHTML {
case .componentPressureLoss:
return "componentLossForm" @Dependency(\.database) var database
@Dependency(\.manualD) var manualD
return await request.view {
await ResultView {
try await catching()
let equipment = try await database.equipment.fetch(projectID)
let componentLosses = try await database.componentLoss.fetch(projectID)
let lengths = try await database.effectiveLength.fetchMax(projectID)
return (
try await database.projects.getCompletedSteps(projectID),
componentLosses,
lengths,
try await manualD.frictionRate(
equipmentInfo: equipment,
componentLosses: componentLosses,
effectiveLength: lengths
)
)
} onSuccess: { (steps, losses, lengths, frictionRate) in
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
FrictionRateView(
componentLosses: losses,
equivalentLengths: lengths,
frictionRateResponse: frictionRate
)
}
}
} }
} }
} }
extension SiteRoute.View.EffectiveLengthRoute { extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .delete(let id):
return await ResultView {
try await database.effectiveLength.delete(id)
}
case .index: case .index:
return _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) { return await self.view(on: request, projectID: projectID)
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
}
case .form(let dismiss):
return EffectiveLengthForm(dismiss: dismiss)
case .field(let type, let style): case .field(let type, let style):
switch type { switch type {
@@ -257,32 +474,206 @@ extension SiteRoute.View.EffectiveLengthRoute {
// FIX: // FIX:
return GroupField(style: style ?? .supply) return GroupField(style: style ?? .supply)
} }
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
}
case .submit(let step):
switch step {
case .one(let stepOne):
return await ResultView {
var effectiveLength: EffectiveLength? = nil
if let id = stepOne.id {
effectiveLength = try await database.effectiveLength.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
EffectiveLengthForm.StepTwo(
projectID: projectID,
stepOne: stepOne,
effectiveLength: effectiveLength
)
}
case .two(let stepTwo):
return await ResultView {
request.logger.debug("ViewController: Got step two...")
var effectiveLength: EffectiveLength? = nil
if let id = stepTwo.id {
effectiveLength = try await database.effectiveLength.get(id)
}
return effectiveLength
} onSuccess: { effectiveLength in
return EffectiveLengthForm.StepThree(
projectID: projectID, effectiveLength: effectiveLength, stepTwo: stepTwo
)
}
case .three(let stepThree):
return await view(on: request, projectID: projectID) {
_ = try await database.effectiveLength.create(
.init(form: stepThree, projectID: projectID)
)
}
}
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.effectiveLength.fetch(projectID)
)
} onSuccess: { (steps, equivalentLengths) in
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
EffectiveLengthsView(effectiveLengths: equivalentLengths)
.environment(ProjectViewValue.$projectID, projectID)
}
}
} }
} }
} }
private func _render<C: HTML>( extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, func renderView(
showSidebar: Bool = true, on request: ViewController.Request,
@HTMLBuilder inner: () async throws -> C projectID: Project.ID
) async throws -> AnySendableHTML where C: Sendable { ) async -> AnySendableHTML {
let inner = try await inner() @Dependency(\.database) var database
if isHtmxRequest { @Dependency(\.manualD) var manualD
return inner
switch self {
case .index:
return await view(on: request, projectID: projectID)
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .roomRectangularForm(let roomID, let form):
return await ResultView {
let room = try await database.rooms.updateRectangularSize(
roomID,
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .trunk(let route):
switch route {
case .delete(let id):
return await ResultView {
try await database.trunkSizes.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.create(
form.toCreate(logger: request.logger)
)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.trunkSizes.update(id, form.toUpdate())
}
}
}
}
func view(
on request: ViewController.Request,
projectID: Project.ID,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
return (
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
}
}
}
} }
return MainPage { inner }
} }
private func _render<C: HTML>( extension SiteRoute.View.UserRoute {
isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, func renderView(on request: ViewController.Request) async -> AnySendableHTML {
showSidebar: Bool = true, switch self {
@HTMLBuilder inner: () -> C case .profile(let route):
) -> AnySendableHTML where C: Sendable { return await route.renderView(on: request)
let inner = inner() }
if isHtmxRequest { }
return inner }
extension SiteRoute.View.UserRoute.Profile {
func renderView(
on request: ViewController.Request
) async -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .index:
return await view(on: request)
case .submit(let form):
return await view(on: request) {
_ = try await database.userProfile.create(form)
}
case .update(let id, let updates):
return await view(on: request) {
_ = try await database.userProfile.update(id, updates)
}
}
}
func view(
on request: ViewController.Request,
catching: @escaping @Sendable () async throws -> Void = {}
) async -> AnySendableHTML {
@Dependency(\.database) var database
return await request.view {
await ResultView {
try await catching()
let user = try request.currentUser()
return (
user,
try await database.userProfile.fetch(user.id)
)
} onSuccess: { (user, profile) in
UserView(user: user, profile: profile)
}
}
} }
return MainPage { inner }
} }

View File

@@ -3,39 +3,69 @@ import ElementaryHTMX
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// FIX: The value field is sometimes wonky as far as what values it accepts.
struct ComponentLossForm: HTML, Sendable { struct ComponentLossForm: HTML, Sendable {
static func id(_ componentLoss: ComponentPressureLoss? = nil) -> String {
let base = "componentLossForm"
guard let componentLoss else { return base }
return "\(base)_\(componentLoss.id.idString)"
}
let dismiss: Bool let dismiss: Bool
let projectID: Project.ID let projectID: Project.ID
let componentLoss: ComponentPressureLoss?
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .componentLoss(.index)))
)
.appendingPath(componentLoss?.id)
}
var body: some HTML { var body: some HTML {
ModalForm(id: "componentLossForm", dismiss: dismiss) { ModalForm(id: Self.id(componentLoss), dismiss: dismiss) {
h1(.class("text-2xl font-bold")) { "Component Loss" } h1(.class("text-2xl font-bold")) { "Component Loss" }
form(.class("space-y-4 p-4")) { form(
div { .class("space-y-4 p-4"),
label(.for("name")) { "Name" } componentLoss == nil
Input(id: "name", placeholder: "Name") ? .hx.post(route)
.attributes(.type(.text), .required, .autofocus) : .hx.patch(route),
} .hx.target("body"),
div { .hx.swap(.outerHTML)
label(.for("value")) { "Value" } ) {
Input(id: "name", placeholder: "Pressure loss")
.attributes(.type(.number), .min("0"), .max("1"), .step("0.1"), .required) if let componentLoss {
} input(.class("hidden"), .name("id"), .value("\(componentLoss.id)"))
Row {
div {}
div {
CancelButton()
.attributes(
.hx.get(
route: .project(
.detail(projectID, .frictionRate(.form(.componentPressureLoss, dismiss: true))))
),
.hx.target("#componentLossForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
} }
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
LabeledInput(
"Name",
.name("name"),
.type(.text),
.value(componentLoss?.name),
.placeholder("Name"),
.required,
.autofocus
)
LabeledInput(
"Value",
.name("value"),
.type(.number),
.value(componentLoss?.value),
.placeholder("0.2"),
.min("0.03"),
.max("1.0"),
.step("0.01"),
.required
)
SubmitButton()
.attributes(.class("btn-block"))
} }
} }
} }

View File

@@ -3,53 +3,88 @@ import ElementaryHTMX
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// TODO: Load component losses when view appears??
struct ComponentPressureLossesView: HTML, Sendable { struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss] let componentPressureLosses: [ComponentPressureLoss]
let projectID: Project.ID let projectID: Project.ID
private var total: Double { private var total: Double {
componentPressureLosses.reduce(into: 0) { $0 += $1.value } componentPressureLosses.total
}
private var sortedLosses: [ComponentPressureLoss] {
componentPressureLosses.sorted {
$0.value > $1.value
}
} }
var body: some HTML { var body: some HTML {
div( div(.class("space-y-4")) {
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4
"""
)
) {
Row { Row {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" } h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
PlusButton() PlusButton()
.attributes( .attributes(
.hx.get( .class("btn-primary text-2xl me-2"),
route: .project( .showModal(id: ComponentLossForm.id())
.detail(projectID, .frictionRate(.form(.componentPressureLoss, dismiss: false))))
),
.hx.target("#componentLossForm"),
.hx.swap(.outerHTML)
) )
.tooltip("Add component loss")
} }
.attributes(.class("px-4"))
for row in componentPressureLosses { table(.class("table table-zebra")) {
Row { thead {
Label { row.name } tr(.class("text-xl font-bold")) {
Number(row.value) th { "Name" }
th { "Value" }
th(.class("min-w-[200px]")) {}
}
}
tbody {
for row in sortedLosses {
TableRow(row: row)
}
} }
.attributes(.class("border-b border-gray-200"))
}
Row {
Label { "Total" }
Number(total)
.attributes(.class("text-xl font-bold"))
} }
} }
ComponentLossForm(dismiss: true, projectID: projectID) ComponentLossForm(dismiss: true, projectID: projectID, componentLoss: nil)
} }
struct TableRow: HTML, Sendable {
let row: ComponentPressureLoss
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg")) {
td { row.name }
td { Number(row.value) }
td {
div(.class("flex join items-end justify-end mx-auto")) {
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?")
)
}
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

@@ -0,0 +1,55 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
let trunks: [DuctSizing.TrunkContainer]
var body: some HTML {
div(.class("space-y-4")) {
PageTitleRow {
div {
PageTitle("Duct Sizes")
Alert(
"""
Must complete all the previous sections to display duct sizing calculations.
"""
)
.hidden(when: rooms.count > 0)
.attributes(.class("text-error font-bold italic mt-4"))
}
}
if rooms.count != 0 {
RoomsTable(rooms: rooms)
PageTitleRow {
PageTitle {
"Trunk / Runout Sizes"
}
PlusButton()
.attributes(
.class("btn-primary"),
.showModal(id: TrunkSizeForm.id())
)
.tooltip("Add trunk / runout")
}
if trunks.count > 0 {
TrunkTable(trunks: trunks, rooms: rooms)
}
}
TrunkSizeForm(rooms: rooms, dismiss: true)
}
}
}

View File

@@ -0,0 +1,76 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct RectangularSizeForm: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let id: String
let room: DuctSizing.RoomContainer
let dismiss: Bool
init(
id: String? = nil,
room: DuctSizing.RoomContainer,
dismiss: Bool = true
) {
self.id = Self.id(room)
self.room = room
self.dismiss = dismiss
}
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .ductSizing(.index)))
)
.appendingPath("room")
.appendingPath(room.roomID)
}
var rowID: String {
DuctSizingView.RoomRow.id(room)
}
var height: Int? {
room.rectangularSize?.height
}
var body: some HTML<HTMLTag.dialog> {
ModalForm(id: id, dismiss: dismiss) {
h1(.class("text-lg pb-6")) { "Rectangular Size" }
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id))
LabeledInput(
"Height",
.name("height"),
.type(.number),
.value(height),
.placeholder("8"),
.min("0"),
.required,
.autofocus
)
SubmitButton()
.attributes(.class("btn-block"))
}
}
}
}

View File

@@ -0,0 +1,167 @@
import Elementary
import ElementaryHTMX
import Foundation
import ManualDCore
import Styleguide
extension DuctSizingView {
// TODO: Remove register ID.
struct RoomsTable: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let rooms: [DuctSizing.RoomContainer]
var body: some HTML<HTMLTag.table> {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Name" }
th { "BTU" }
th { "CFM" }
th { "Velocity" }
th(.class("w-[330px]")) { "Size" }
}
}
tbody {
for room in rooms {
RoomRow(room: room)
}
}
}
}
}
struct RoomRow: HTML, Sendable {
static func id(_ room: DuctSizing.RoomContainer) -> String {
"roomRow_\(room.roomName.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let room: DuctSizing.RoomContainer
let formID = UUID().idString
var deleteRoute: String {
guard let id = room.rectangularSize?.id else { return "" }
return SiteRoute.View.router.path(
for: .project(
.detail(
projectID,
.ductSizing(
.deleteRectangularSize(
room.roomID,
.init(rectangularSizeID: id, register: room.roomRegister)
))
)
)
)
}
var rowID: String { Self.id(room) }
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg"), .id(rowID)) {
td { room.roomName }
td {
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
span(.class("label")) { "Heating" }
Number(room.heatingLoad, digits: 0)
span(.class("label")) { "Cooling" }
Number(room.coolingLoad, digits: 0)
}
}
td {
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
span(.class("label")) { "Design" }
div(.class("flex justify-center")) {
Badge(number: room.designCFM.value, digits: 0)
}
span(.class("label")) { "Heating" }
div(.class("flex justify-center")) {
Number(room.heatingCFM, digits: 0)
}
span(.class("label")) { "Cooling" }
div(.class("flex justify-center")) {
Number(room.coolingCFM, digits: 0)
}
}
}
td { Number(room.velocity) }
td {
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: room.roundSize, digits: 2)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: room.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: room.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = room.rectangularWidth,
let height = room.rectangularSize?.height
{
Badge {
span { "\(width) x \(height)" }
}
.attributes(.class("badge-info"))
}
}
div(.class("flex justify-end")) {
div(.class("join")) {
if room.rectangularSize != nil {
Tooltip("Delete Size", position: .bottom) {
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(deleteRoute),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML),
when: room.rectangularSize != nil
)
}
}
Tooltip("Edit Size", position: .bottom) {
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RectangularSizeForm.id(room))
)
}
}
}
RectangularSizeForm(room: room)
}
}
}
}
}
}

View File

@@ -0,0 +1,131 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct TrunkSizeForm: HTML, Sendable {
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String {
let base = "trunkSizeForm"
guard let trunk else { return base }
return "\(base)_\(trunk.id.idString)"
}
@Environment(ProjectViewValue.$projectID) var projectID
let container: DuctSizing.TrunkContainer?
let rooms: [DuctSizing.RoomContainer]
let dismiss: Bool
var trunk: DuctSizing.TrunkSize? {
container?.trunk
}
init(
trunk: DuctSizing.TrunkContainer? = nil,
rooms: [DuctSizing.RoomContainer],
dismiss: Bool = true
) {
self.container = trunk
self.rooms = rooms
self.dismiss = dismiss
}
var route: String {
SiteRoute.View.router
.path(for: .project(.detail(projectID, .ductSizing(.index))))
.appendingPath(SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkRoute.rootPath)
.appendingPath(trunk?.id)
}
var body: some HTML {
ModalForm(id: Self.id(container), dismiss: dismiss) {
h1(.class("text-lg font-bold mb-4")) { "Trunk / Runout Size" }
form(
.class("space-y-4"),
trunk == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value(projectID))
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type")) {
for type in DuctSizing.TrunkSize.TrunkType.allCases {
option(.value(type.rawValue)) { type.rawValue.capitalized }
.attributes(.selected, when: trunk?.type == type)
}
}
}
LabeledInput(
"Height",
.type(.text),
.name("height"),
.value(trunk?.height),
.placeholder("8 (Optional)"),
)
}
LabeledInput(
"Name",
.type(.text),
.name("name"),
.value(trunk?.name),
.placeholder("Trunk-1 (Optional)")
)
div {
h2(.class("label font-bold col-span-3 mb-6")) { "Associated Supply Runs" }
div(
.class(
"""
grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4
"""
)
) {
for room in rooms {
div(.class("block grow")) {
div(.class("grid grid-cols-1 space-y-1")) {
div(.class("flex justify-center")) {
p(.class("label")) { room.roomName }
}
div(.class("flex justify-center")) {
input(
.class("checkbox"),
.type(.checkbox),
.name("rooms"),
.value("\(room.roomID)_\(room.roomRegister)")
)
.attributes(
.checked,
when: trunk == nil ? false : trunk!.rooms.hasRoom(room)
)
}
}
}
}
}
}
SubmitButton()
.attributes(.class("btn-block mt-6"))
}
}
}
}
extension Array where Element == DuctSizing.TrunkSize.RoomProxy {
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool {
first {
$0.id == room.roomID
&& $0.registers.contains(room.roomRegister)
} != nil
}
}

View File

@@ -0,0 +1,152 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
extension DuctSizingView {
struct TrunkTable: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
private var sortedTrunks: [DuctSizing.TrunkContainer] {
trunks
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
}
var body: some HTML {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Name / Type" }
th { "Associated Supplies" }
th { "Dsn CFM" }
th { "Velocity" }
th(.class("w-[330px]")) { "Size" }
}
}
tbody {
for trunk in sortedTrunks {
TrunkRow(trunk: trunk, rooms: rooms)
}
}
}
}
}
struct TrunkRow: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let trunk: DuctSizing.TrunkContainer
let rooms: [DuctSizing.RoomContainer]
var body: some HTML<HTMLTag.tr> {
tr {
td {
div(.class("grid grid-cols-1 space-y-2")) {
if let name = trunk.name {
p(.class("w-fit")) { name }
}
Badge {
trunk.trunk.type.rawValue
}
.attributes(.class("badge-info"), when: trunk.type == .supply)
.attributes(.class("badge-error"), when: trunk.type == .return)
}
}
td {
div(.class("flex flex-wrap space-x-2 space-y-2")) {
for id in registerIDS {
Badge { id }
}
}
}
td {
Number(trunk.designCFM.value, digits: 0)
}
td {
Number(trunk.velocity)
}
td {
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
div(.class("label")) { "Calculated" }
div(.class("flex justify-center")) {
Badge(number: trunk.roundSize, digits: 1)
}
div {}
div(.class("label")) { "Final" }
div(.class("flex justify-center")) {
Badge(number: trunk.finalSize)
.attributes(.class("badge-secondary"))
}
div {}
div(.class("label")) { "Flex" }
div(.class("flex justify-center")) {
Badge(number: trunk.flexSize)
.attributes(.class("badge-primary"))
}
div {}
div(.class("label")) { "Rectangular" }
div(.class("flex justify-center")) {
if let width = trunk.width,
let height = trunk.ductSize.height
{
Badge {
span { "\(width) x \(height)" }
}
.attributes(.class("badge-info"))
}
}
div(.class("flex justify-end items-end")) {
div(.class("join")) {
if trunk.width != nil {
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(route: deleteRoute),
.hx.target("closest tr"),
.hx.swap(.outerHTML)
)
}
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: TrunkSizeForm.id(trunk))
)
}
}
}
TrunkSizeForm(trunk: trunk, rooms: rooms, dismiss: true)
}
}
}
private var deleteRoute: SiteRoute.View {
.project(.detail(projectID, .ductSizing(.trunk(.delete(trunk.id)))))
}
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()
}
}
}

View File

@@ -3,76 +3,223 @@ import ElementaryHTMX
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// TODO: May need a multi-step form were the the effective length type is // TODO: Add back buttons / capability??
// 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.
struct EffectiveLengthForm: HTML, Sendable { struct EffectiveLengthForm: HTML, Sendable {
static func id(_ equivalentLength: EffectiveLength?) -> String {
let base = "equivalentLengthForm"
guard let equivalentLength else { return base }
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
}
let projectID: Project.ID
let dismiss: Bool let dismiss: Bool
let type: EffectiveLength.EffectiveLengthType let type: EffectiveLength.EffectiveLengthType
let effectiveLength: EffectiveLength?
init(dismiss: Bool, type: EffectiveLength.EffectiveLengthType = .supply) { var id: String { Self.id(effectiveLength) }
init(
projectID: Project.ID,
dismiss: Bool,
type: EffectiveLength.EffectiveLengthType = .supply
) {
self.projectID = projectID
self.dismiss = dismiss self.dismiss = dismiss
self.type = type self.type = type
self.effectiveLength = nil
}
init(
effectiveLength: EffectiveLength
) {
self.dismiss = true
self.type = effectiveLength.type
self.projectID = effectiveLength.projectID
self.effectiveLength = effectiveLength
} }
var body: some HTML { var body: some HTML {
ModalForm(id: "effectiveLengthForm", dismiss: dismiss) { ModalForm(
id: id,
dismiss: dismiss
) {
h1(.class("text-2xl font-bold")) { "Effective Length" } h1(.class("text-2xl font-bold")) { "Effective Length" }
form(.class("space-y-4 p-4")) { div(.id("formStep_\(id)"), .class("mt-4")) {
div { StepOne(projectID: projectID, effectiveLength: effectiveLength)
label(.for("name")) { "Name" } }
Input(id: "name", placeholder: "Name") }
.attributes(.type(.text), .required, .autofocus) }
struct StepOne: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
for: .project(.detail(projectID, .equivalentLength(.index)))
)
return "\(baseRoute)/stepOne"
}
var body: some HTML {
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"),
.hx.swap(.innerHTML)
) {
if let id = effectiveLength?.id {
input(.class("hidden"), .name("id"), .value("\(id)"))
} }
div {
label(.for("type")) { "Type" } LabeledInput(
GroupTypeSelect(selected: type) "Name",
.attributes(.class("w-full border rounded-md")) .name("name"),
.type(.text),
.value(effectiveLength?.name),
.required,
.autofocus
)
GroupTypeSelect(projectID: projectID, selected: effectiveLength?.type ?? .supply)
Row {
div {}
SubmitButton(title: "Next")
} }
}
}
}
struct StepTwo: HTML, Sendable {
let projectID: Project.ID
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
let effectiveLength: EffectiveLength?
var route: String {
let baseRoute = SiteRoute.View.router.path(
for: .project(.detail(projectID, .equivalentLength(.index)))
)
return "\(baseRoute)/stepTwo"
}
var body: some HTML {
form(
.class("space-y-4"),
.hx.post(route),
.hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"),
.hx.swap(.innerHTML)
) {
if let id = effectiveLength?.id {
input(.class("hidden"), .name("id"), .value("\(id)"))
}
input(.class("hidden"), .name("name"), .value(stepOne.name))
input(.class("hidden"), .name("type"), .value(stepOne.type.rawValue))
Row { Row {
Label { "Straigth Lengths" } Label { "Straigth Lengths" }
button( button(
.type(.button), .type(.button),
.hx.get(route: .effectiveLength(.field(.straightLength))), .hx.get(
route: .project(.detail(projectID, .equivalentLength(.field(.straightLength))))
),
.hx.target("#straightLengths"), .hx.target("#straightLengths"),
.hx.swap(.beforeEnd) .hx.swap(.beforeEnd)
) { ) {
SVG(.circlePlus) SVG(.circlePlus)
} }
} }
div(.id("straightLengths")) { div(.id("straightLengths"), .class("space-y-4")) {
StraightLengthField() if let effectiveLength {
for length in effectiveLength.straightLengths {
StraightLengthField(value: length)
}
} else {
StraightLengthField()
}
}
Row {
div {}
SubmitButton(title: "Next")
}
}
}
}
struct StepThree: HTML, Sendable {
let projectID: Project.ID
let effectiveLength: EffectiveLength?
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
var route: String {
let baseRoute = SiteRoute.View.router.path(
for: .project(.detail(projectID, .equivalentLength(.index)))
)
if let effectiveLength {
return baseRoute.appendingPath(effectiveLength.id)
} else {
return baseRoute.appendingPath("stepThree")
}
}
var body: some HTML {
form(
.class("space-y-4"),
effectiveLength == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
if let id = effectiveLength?.id {
input(.class("hidden"), .name("id"), .value("\(id)"))
}
input(.class("hidden"), .name("name"), .value(stepTwo.name))
input(.class("hidden"), .name("type"), .value(stepTwo.type.rawValue))
for length in stepTwo.straightLengths {
input(.class("hidden"), .name("straightLengths"), .value("\(length)"))
} }
Row { Row {
Label { "Groups" } Label { "Groups" }
button( button(
.type(.button), .type(.button),
.hx.get(route: .effectiveLength(.field(.group, style: type))), .hx.get(
route: .project(
.detail(projectID, .equivalentLength(.field(.group, style: stepTwo.type))))
),
.hx.target("#groups"), .hx.target("#groups"),
.hx.swap(.beforeEnd) .hx.swap(.beforeEnd)
) { ) {
SVG(.circlePlus) SVG(.circlePlus)
} }
} }
div(.id("groups"), .class("space-y-4")) {
GroupField(style: type) a(
.href("/files/ManD.Groups.pdf"),
.target(.blank),
.class("btn btn-link")
) {
"Click here for Manual-D groups reference."
} }
div(.id("groups"), .class("space-y-4")) {
if let effectiveLength {
for group in effectiveLength.groups {
GroupField(style: effectiveLength.type, group: group)
}
} else {
GroupField(style: stepTwo.type)
}
}
Row { Row {
div {} div {}
div(.class("space-x-4")) { SubmitButton()
CancelButton()
.attributes(
.hx.get(route: .effectiveLength(.form(dismiss: true))),
.hx.target("#effectiveLengthForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
} }
} }
} }
@@ -87,33 +234,74 @@ struct StraightLengthField: HTML, Sendable {
} }
var body: some HTML<HTMLTag.div> { var body: some HTML<HTMLTag.div> {
div(.class("pb-4")) { Row {
Input( LabeledInput(
name: "straightLengths[]", "Length",
placeholder: "Length" .name("straightLengths"),
.type(.number),
.value(value),
.placeholder("10"),
.min("0"),
.autofocus,
.required
) )
.attributes(.type(.number), .min("0")) TrashButton()
.attributes(.data("remove", value: "true"))
} }
.attributes(.hx.ext("remove"), .class("space-x-4"))
} }
} }
struct GroupField: HTML, Sendable { struct GroupField: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType let style: EffectiveLength.EffectiveLengthType
let group: EffectiveLength.Group?
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) {
self.style = style
self.group = group
}
var body: some HTML { var body: some HTML {
Row { div(.class("grid grid-cols-3 gap-2 p-2 border rounded-lg shadow-sm")) {
// Input(name: "group[][group]", placeholder: "Group")
// .attributes(.type(.number), .min("0"))
GroupSelect(style: style) GroupSelect(style: style)
Input(name: "group[][letter]", placeholder: "Letter")
.attributes(.type(.text)) LabeledInput(
Input(name: "group[][length]", placeholder: "Length") "Letter",
.attributes(.type(.number), .min("0")) .name("group[letter]"),
Input(name: "group[][quantity]", placeholder: "Quantity") .type(.text),
.attributes(.type(.number), .min("1"), .value("1")) .value(group?.letter),
.placeholder("a"),
.required
)
LabeledInput(
"Length",
.name("group[length]"),
.type(.number),
.value(group?.value),
.placeholder("10"),
.min("0"),
.required
)
LabeledInput(
"Quantity",
.name("group[quantity]"),
.type(.number),
.value(group?.quantity ?? 1),
.min("1"),
.required
)
.attributes(.class("col-span-2"))
TrashButton()
.attributes(
.data("remove", value: "true"),
.class("me-2 btn-block")
)
} }
.attributes(.class("space-x-2")) .attributes(.class("space-x-2"), .hx.ext("remove"))
} }
} }
@@ -122,11 +310,15 @@ struct GroupSelect: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType let style: EffectiveLength.EffectiveLengthType
var body: some HTML { var body: some HTML {
select( label(.class("select")) {
.name("group") span(.class("label")) { "Group" }
) { select(
for value in style.selectOptions { .name("group[group]"),
option(.value("\(value)")) { "\(value)" } .autofocus
) {
for value in style.selectOptions {
option(.value("\(value)")) { "\(value)" }
}
} }
} }
} }
@@ -135,19 +327,19 @@ struct GroupSelect: HTML, Sendable {
struct GroupTypeSelect: HTML, Sendable { struct GroupTypeSelect: HTML, Sendable {
var selected: EffectiveLength.EffectiveLengthType let projectID: Project.ID
let selected: EffectiveLength.EffectiveLengthType
var body: some HTML<HTMLTag.select> { var body: some HTML<HTMLTag.label> {
select(.name("type"), .id("type")) { label(.class("select w-full")) {
for value in EffectiveLength.EffectiveLengthType.allCases { span(.class("label")) { "Type" }
option( select(.name("type"), .id("type")) {
.value("\(value.rawValue)"), for value in EffectiveLength.EffectiveLengthType.allCases {
.hx.get(route: .effectiveLength(.field(.group, style: value))), option(
.hx.target("#groups"), .value("\(value.rawValue)"),
.hx.swap(.beforeEnd), ) { value.title }
.hx.trigger(.event(.change).from("#type")) .attributes(.selected, when: value == selected)
) { value.title } }
.attributes(.selected, when: value == selected)
} }
} }
} }
@@ -162,7 +354,7 @@ extension EffectiveLength.EffectiveLengthType {
case .return: case .return:
return [5, 6, 7, 8, 10, 11, 12] return [5, 6, 7, 8, 10, 11, 12]
case .supply: case .supply:
return [1, 2, 4, 8, 9, 11, 12] return [1, 2, 3, 4, 8, 9, 11, 12]
} }
} }
} }

View File

@@ -0,0 +1,139 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct EffectiveLengthsTable: HTML, Sendable {
let effectiveLengths: [EffectiveLength]
private var sortedLengths: [EffectiveLength] {
effectiveLengths.sorted {
$0.totalEquivalentLength > $1.totalEquivalentLength
}
.sorted {
$0.type.rawValue > $1.type.rawValue
}
}
var body: some HTML<HTMLTag.table> {
table(.class("table table-zebra text-lg")) {
thead {
tr(.class("text-lg")) {
th { "Type" }
th { "Name" }
th { "Straight Lengths" }
th {
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
div(.class("flex justify-center col-span-3")) {
"Groups"
}
div { "Group" }
div(.class("flex justify-center")) {
"T.E.L."
}
div(.class("flex justify-end")) {
"Quantity"
}
}
}
th {
div(.class("flex justify-end me-[140px]")) {
"T.E.L."
}
}
}
}
tbody {
for row in sortedLengths {
EffectiveLenghtRow(effectiveLength: row)
}
}
}
}
struct EffectiveLenghtRow: HTML, Sendable {
let effectiveLength: EffectiveLength
private var deleteRoute: SiteRoute.View {
.project(
.detail(
effectiveLength.projectID,
.equivalentLength(.delete(id: effectiveLength.id))
)
)
}
var body: some HTML<HTMLTag.tr> {
tr(.id(effectiveLength.id.idString)) {
td {
// Type
Badge {
span { effectiveLength.type.rawValue }
}
.attributes(.class("badge-info"), when: effectiveLength.type == .supply)
.attributes(.class("badge-error"), when: effectiveLength.type == .return)
}
td { effectiveLength.name }
td {
// Lengths
div(.class("grid grid-cols-1 gap-2")) {
for length in effectiveLength.straightLengths {
Number(length)
}
}
}
td {
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
for group in effectiveLength.groups {
span { "\(group.group)-\(group.letter)" }
div(.class("flex justify-center")) {
Number(group.value)
}
div(.class("flex justify-end")) {
Number(group.quantity)
}
}
}
}
td {
// Total
// Row {
div(.class("flex justify-end mx-auto space-x-4")) {
Badge(number: effectiveLength.totalEquivalentLength, digits: 0)
.attributes(.class("badge-primary badge-lg pt-2"))
// Buttons
div(.class("flex justify-end -mt-2")) {
div(.class("join")) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(route: deleteRoute),
.hx.confirm("Are you sure?"),
.hx.target("#\(effectiveLength.id.idString)"),
.hx.swap(.outerHTML)
)
.tooltip("Delete", position: .bottom)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: EffectiveLengthForm.id(effectiveLength))
)
.tooltip("Edit", position: .bottom)
}
}
}
EffectiveLengthForm(effectiveLength: effectiveLength)
}
}
}
}
}

View File

@@ -5,100 +5,37 @@ import Styleguide
struct EffectiveLengthsView: HTML, Sendable { struct EffectiveLengthsView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let effectiveLengths: [EffectiveLength] let effectiveLengths: [EffectiveLength]
var supplies: [EffectiveLength] {
effectiveLengths.filter({ $0.type == .supply })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}
var returns: [EffectiveLength] {
effectiveLengths.filter({ $0.type == .return })
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
}
var body: some HTML { var body: some HTML {
div( div(.class("space-y-4")) {
.class("m-4") PageTitleRow {
) { PageTitle { "Equivalent Lengths" }
Row {
h1(.class("text-2xl font-bold")) { "Effective Lengths" }
PlusButton() PlusButton()
.attributes( .attributes(
.hx.get(route: .effectiveLength(.form(dismiss: false))), .class("btn-primary"),
.hx.target("#effectiveLengthForm"), .showModal(id: EffectiveLengthForm.id(nil))
.hx.swap(.outerHTML)
) )
// button( .tooltip("Add equivalent length")
// .hx.get(route: .effectiveLength(.form(dismiss: false))),
// .hx.target("#effectiveLengthForm"),
// .hx.swap(.outerHTML)
// ) {
// Icon(.circlePlus)
// }
} }
.attributes(.class("pb-6")) .attributes(.class("pb-6"))
div( EffectiveLengthForm(projectID: projectID, dismiss: true)
.id("effectiveLengths"),
.class("space-y-6")
) {
for row in effectiveLengths {
EffectiveLengthView(effectiveLength: row)
}
}
EffectiveLengthForm(dismiss: true) EffectiveLengthsTable(effectiveLengths: effectiveLengths)
}
}
private struct EffectiveLengthView: HTML, Sendable {
let effectiveLength: EffectiveLength
var straightLengthsTotal: Int {
effectiveLength.straightLengths
.reduce(into: 0) { $0 += $1 }
}
var groupsTotal: Double {
effectiveLength.groups.reduce(into: 0) {
$0 += ($1.value * Double($1.quantity))
}
}
var body: some HTML<HTMLTag.div> {
div(
.class(
"""
border border-gray-200 rounded-lg shadow-lg p-4
"""
)
) {
Row {
span(.class("text-xl font-bold")) { effectiveLength.name }
}
Row {
Label("Straight Lengths")
}
for length in effectiveLength.straightLengths {
Row {
div {}
Number(length)
}
}
Row {
Label("Groups")
Label("Equivalent Length")
Label("Quantity")
}
.attributes(.class("border-b border-gray-200"))
for group in effectiveLength.groups {
Row {
span { "\(group.group)-\(group.letter)" }
Number(group.value)
Number(group.quantity)
}
}
Row {
Label("Total")
Number(Double(straightLengthsTotal) + groupsTotal, digits: 0)
.attributes(.class("text-xl font-bold"))
}
.attributes(.class("border-b border-t border-gray-200"))
}
} }
} }
} }

View File

@@ -7,8 +7,9 @@ struct EquipmentInfoForm: HTML, Sendable {
static let id = "equipmentForm" static let id = "equipmentForm"
@Environment(ProjectViewValue.$projectID) var projectID
let dismiss: Bool let dismiss: Bool
let projectID: Project.ID
let equipmentInfo: EquipmentInfo? let equipmentInfo: EquipmentInfo?
var staticPressure: String { var staticPressure: String {
@@ -18,29 +19,22 @@ struct EquipmentInfoForm: HTML, Sendable {
return "\(staticPressure)" return "\(staticPressure)"
} }
var heatingCFM: String { var route: String {
guard let heatingCFM = equipmentInfo?.heatingCFM else { SiteRoute.View.router.path(
return "" for: .project(.detail(projectID, .equipment(.index)))
} )
return "\(heatingCFM)" .appendingPath(equipmentInfo?.id)
}
var coolingCFM: String {
guard let heatingCFM = equipmentInfo?.heatingCFM else {
return ""
}
return "\(heatingCFM)"
} }
var body: some HTML { var body: some HTML {
ModalForm(id: Self.id, dismiss: dismiss) { ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" } h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" }
form( form(
.class("space-y-4 p-4"), .class("grid grid-cols-1 gap-4"),
equipmentInfo != nil equipmentInfo != nil
? .hx.patch(route: .project(.detail(projectID, .equipment(.index)))) ? .hx.patch(route)
: .hx.post(route: .project(.detail(projectID, .equipment(.index)))), : .hx.post(route),
.hx.target("#equipmentInfo"), .hx.target("body"),
.hx.swap(.outerHTML) .hx.swap(.outerHTML)
) { ) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)")) input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
@@ -49,27 +43,40 @@ struct EquipmentInfoForm: HTML, Sendable {
input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)")) input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)"))
} }
div { LabeledInput(
label(.for("staticPressure")) { "Static Pressure" } "Static Pressure",
Input(id: "staticPressure", placeholder: "Static pressure") .name("staticPressure"),
.attributes( .type(.number),
.type(.number), .value(staticPressure), .min("0"), .max("1.0"), .step("0.1") .value(staticPressure),
) .min("0"),
} .max("1.0"),
div { .step("0.1"),
label(.for("heatingCFM")) { "Heating CFM" } .required
Input(id: "heatingCFM", placeholder: "CFM") )
.attributes(.type(.number), .min("0"), .value(heatingCFM))
} LabeledInput(
div { "Heating CFM",
label(.for("coolingCFM")) { "Cooling CFM" } .name("heatingCFM"),
Input(id: "coolingCFM", placeholder: "CFM") .type(.number),
.attributes(.type(.number), .min("0"), .value(coolingCFM)) .value(equipmentInfo?.heatingCFM),
} .placeholder("1000"),
div { .min("0"),
SubmitButton(title: "Save") .required,
.attributes(.class("btn-block")) .autofocus
} )
LabeledInput(
"Cooling CFM",
.name("coolingCFM"),
.type(.number),
.value(equipmentInfo?.coolingCFM),
.placeholder("1000"),
.min("0"),
.required
)
SubmitButton(title: "Save")
.attributes(.class("btn-block my-6"))
} }
} }
} }

View File

@@ -8,42 +8,55 @@ struct EquipmentInfoView: HTML, Sendable {
var body: some HTML { var body: some HTML {
div( div(
.class("space-y-4 border border-gray-200 rounded-lg shadow-lg p-4"), .class("space-y-4"),
.id("equipmentInfo") .id("equipmentInfo")
) { ) {
Row { PageTitleRow {
h1(.class("text-2xl font-bold")) { "Equipment Info" } PageTitle { "Equipment Details" }
EditButton() EditButton()
.attributes( .attributes(
.on(.click, "\(EquipmentInfoForm.id).showModal()") .class("btn-primary"),
.showModal(id: EquipmentInfoForm.id)
) )
.tooltip("Edit equipment details")
} }
if let equipmentInfo { if let equipmentInfo {
Row { table(.class("table table-zebra")) {
Label { "Static Pressure" } tbody(.class("text-lg")) {
Number(equipmentInfo.staticPressure) tr {
td { Label { "Static Pressure" } }
td {
div(.class("flex justify-end")) {
Number(equipmentInfo.staticPressure)
}
}
}
tr {
td { Label { "Heating CFM" } }
td {
div(.class("flex justify-end")) {
Number(equipmentInfo.heatingCFM)
}
}
}
tr {
td { Label { "Cooling CFM" } }
td {
div(.class("flex justify-end")) {
Number(equipmentInfo.coolingCFM)
}
}
}
}
} }
.attributes(.class("border-b border-gray-200"))
Row {
Label { "Heating CFM" }
Number(equipmentInfo.heatingCFM)
}
.attributes(.class("border-b border-gray-200"))
Row {
Label { "Cooling CFM" }
Number(equipmentInfo.coolingCFM)
}
.attributes(.class("border-b border-gray-200"))
} }
EquipmentInfoForm( EquipmentInfoForm(
dismiss: true, projectID: projectID, equipmentInfo: equipmentInfo dismiss: equipmentInfo != nil,
equipmentInfo: equipmentInfo
) )
} }
} }

View File

@@ -1,17 +1,148 @@
import Elementary import Elementary
import ManualDClient
import ManualDCore import ManualDCore
import Styleguide import Styleguide
struct FrictionRateView: HTML, Sendable { struct FrictionRateView: HTML, Sendable {
let equipmentInfo: EquipmentInfo? @Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss] let componentLosses: [ComponentPressureLoss]
let projectID: Project.ID let equivalentLengths: EffectiveLength.MaxContainer
let frictionRateResponse: ManualDClient.FrictionRateResponse?
private var availableStaticPressure: Double? {
frictionRateResponse?.availableStaticPressure
}
private var frictionRateDesignValue: Double? {
frictionRateResponse?.frictionRate
}
private var shouldShowBadges: Bool {
frictionRateDesignValue != nil || availableStaticPressure != nil
}
private var badgeColor: String {
let base = "badge-info"
guard let frictionRateDesignValue else { return base }
if frictionRateDesignValue >= 0.18 || frictionRateDesignValue <= 0.02 {
return "badge-error"
}
return base
}
private var showHighErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue >= 0.18
}
private var showLowErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue <= 0.02
}
private var showNoComponentLossesError: Bool {
componentLosses.count == 0
}
private var showIncompleteSectionsError: Bool {
availableStaticPressure == nil || frictionRateDesignValue == nil
}
private var hasAlerts: Bool {
showLowErrors
|| showHighErrors
|| showNoComponentLossesError
|| showIncompleteSectionsError
}
var body: some HTML { var body: some HTML {
div(.class("p-4 space-y-6")) { div(.class("space-y-6")) {
h1(.class("text-4xl font-bold pb-6")) { "Friction Rate" } PageTitleRow {
EquipmentInfoView(equipmentInfo: equipmentInfo, projectID: projectID) div(.class("grid grid-cols-2 px-4 w-full")) {
PageTitle { "Friction Rate" }
div(.class("space-y-2 justify-end font-bold text-lg")) {
if shouldShowBadges {
if let frictionRateDesignValue {
LabeledContent {
span { "Friction Rate Design Value" }
} content: {
Badge(number: frictionRateDesignValue, digits: 2)
.attributes(.class("\(badgeColor) badge-lg"))
.bold()
}
.attributes(.class("justify-end mx-auto"))
}
if let availableStaticPressure {
LabeledContent {
span { "Available Static Pressure" }
} content: {
Badge(number: availableStaticPressure, digits: 2)
}
.attributes(.class("justify-end mx-auto"))
}
LabeledContent {
span { "Component Pressure Losses" }
} content: {
Badge(number: componentLosses.total, digits: 2)
}
.attributes(.class("justify-end mx-auto"))
}
}
div(.class("text-error font-bold italic col-span-2")) {
Alert {
p {
"Must complete previous sections."
}
}
.hidden(when: !showIncompleteSectionsError)
Alert {
p {
"No component pressures losses"
}
}
.hidden(when: !showNoComponentLossesError)
Alert {
p(.class("block")) {
"Calculated friction rate is below 0.02. The fan may not deliver the required CFM."
br()
" * Increase the blower speed"
br()
" * Increase the blower size"
br()
" * Decrease the Total Effective Length (TEL)"
}
}
.hidden(when: !showLowErrors)
Alert {
p(.class("block")) {
"Calculated friction rate is above 0.18. The fan may deliver too many CFM."
br()
" * Decrease the blower speed"
br()
" * Decreae the blower size"
br()
" * Increase the Total Effective Length (TEL)"
}
}
.hidden(when: !showHighErrors)
}
.attributes(.class("mt-4"), when: hasAlerts)
}
}
ComponentPressureLossesView( ComponentPressureLossesView(
componentPressureLosses: componentLosses, projectID: projectID componentPressureLosses: componentLosses, projectID: projectID
) )

View File

@@ -1,38 +1,111 @@
import Elementary import Elementary
import ElementaryHTMX import ElementaryHTMX
import Foundation
import ManualDCore import ManualDCore
import Styleguide import Styleguide
public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable { public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
public var title: String { "Manual-D" } public var title: String { "Duct Calc" }
public var lang: String { "en" } public var lang: String { "en" }
let inner: Inner let inner: Inner
let theme: Theme?
let displayFooter: Bool
init( init(
displayFooter: Bool = true,
theme: Theme? = nil,
_ inner: () -> Inner _ inner: () -> Inner
) { ) {
self.displayFooter = displayFooter
self.theme = theme
self.inner = inner() self.inner = inner()
} }
private var summary: String {
"""
Duct sizing based on ACCA, Manual-D.
"""
}
private var keywords: String {
"""
duct, hvac, duct-design, duct design, manual-d, manual d, design
"""
}
public var head: some HTML { public var head: some HTML {
meta(.charset(.utf8)) meta(.charset(.utf8))
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0")) meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
meta(.content("ductcalc.com"), .name("og:site_name"))
meta(.content("Duct Calc"), .name("og:title"))
meta(.content(summary), .name("description"))
meta(.content(summary), .name("og:description"))
meta(.content("/images/mand_logo.png"), .name("og:image"))
meta(.content("/images/mand_logo.png"), .name("twitter:image"))
meta(.content("Duct Calc"), .name("twitter:image:alt"))
meta(.content("summary_large_image"), .name("twitter:card"))
meta(.content("1536"), .name("og:image:width"))
meta(.content("1024"), .name("og:image:height"))
meta(.content(keywords), .name(.keywords))
script(.src("https://unpkg.com/htmx.org@2.0.8")) {} script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
script(.src("/js/main.js")) {} script(.src("/js/main.js")) {}
link(.rel(.stylesheet), .href("/css/output.css")) link(.rel(.stylesheet), .href("/css/output.css"))
link(.rel(.icon), .href("/images/favicon.ico"), .custom(name: "type", value: "image/x-icon")) link(
.rel(.icon),
.href("/images/favicon.ico"),
.init(name: "type", value: "image/x-icon")
)
link(
.rel(.icon),
.href("/images/favicon-32x32.png"),
.init(name: "type", value: "image/png")
)
link(
.rel(.icon),
.href("/images/favicon-16x16.png"),
.init(name: "type", value: "image/png")
)
link(
.rel(.init(rawValue: "apple-touch-icon")),
.init(name: "sizes", value: "180x180"),
.href("/images/apple-touch-icon.png")
)
link(.rel(.init(rawValue: "manifest")), .href("/site.webmanifest"))
script(
.src("https://unpkg.com/htmx-remove@latest"),
.crossorigin(.anonymous),
.integrity("sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT")
) {}
} }
public var body: some HTML { public var body: some HTML {
div(.class("h-screen w-full")) { div(.class("flex flex-col min-h-screen min-w-full justify-between")) {
inner main(.class("flex flex-col min-h-screen min-w-full grow mb-auto")) {
} inner
script(.src("https://unpkg.com/lucide@latest")) {} }
script {
"lucide.createIcons();" div(.class("bottom-0 left-0 bg-error")) {
if displayFooter {
footer(
.class(
"""
footer sm:footer-horizontal footer-center
bg-base-300 text-base-content p-4
"""
)
) {
aside {
p {
"Copyright © \(Date().description.prefix(4)) - All rights reserved by Michael Housh"
}
}
}
}
}
} }
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
} }
} }

View File

@@ -0,0 +1,72 @@
import Elementary
import ManualDCore
import Styleguide
struct Navbar: HTML, Sendable {
let sidebarToggle: Bool
let userProfile: Bool
init(
sidebarToggle: Bool,
userProfile: Bool = true
) {
self.sidebarToggle = sidebarToggle
self.userProfile = userProfile
}
var body: some HTML<HTMLTag.nav> {
nav(
.class(
"""
navbar w-full bg-base-300 text-base-content shadow-sm mb-4
"""
)
) {
div(.class("flex flex-1 space-x-4 items-center")) {
if sidebarToggle {
label(
.for("my-drawer-1"),
.class("size-7"),
.init(name: "aria-label", value: "open sidebar")
) {
SVG(.sidebarToggle)
}
.navButton()
.tooltip("Open sidebar", position: .right)
}
a(
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
.href(route: .project(.index))
) {
img(
.src("/images/mand_logo_sm.webp"),
)
span { "Duct Calc" }
}
.navButton()
.tooltip("Home", position: .right)
}
if userProfile {
// TODO: Make dropdown
div(.class("flex-none")) {
a(
.href(route: .user(.profile(.index))),
) {
SVG(.circleUser)
}
.navButton()
.tooltip("Profile")
}
}
}
}
}
extension HTML where Tag: HTMLTrait.Attributes.Global {
func navButton() -> _AttributedElement<Self> {
attributes(
.class("btn btn-square btn-ghost hover:bg-neutral hover:text-white")
)
}
}

View File

@@ -7,51 +7,65 @@ struct ProjectDetail: HTML, Sendable {
let project: Project let project: Project
var body: some HTML { var body: some HTML {
div( div {
.class( PageTitleRow {
""" PageTitle { "Project" }
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4 m-4
"""
)
) {
Row {
h1(.class("text-2xl font-bold")) { "Project" }
EditButton() EditButton()
.attributes( .attributes(
.class("btn-primary"),
.on(.click, "projectForm.showModal()") .on(.click, "projectForm.showModal()")
) )
.tooltip("Edit project", position: .left)
} }
Row { table(.class("table table-zebra text-lg")) {
Label("Name") tbody {
span { project.name } tr {
td(.class("label font-bold")) { "Name" }
td {
div(.class("flex justify-end")) {
project.name
}
}
}
tr {
td(.class("label font-bold")) { "Street Address" }
td {
div(.class("flex justify-end")) {
project.streetAddress
}
}
}
tr {
td(.class("label font-bold")) { "City" }
td {
div(.class("flex justify-end")) {
project.city
}
}
}
tr {
td(.class("label font-bold")) { "State" }
td {
div(.class("flex justify-end")) {
project.state
}
}
}
tr {
td(.class("label font-bold")) { "Zip" }
td {
div(.class("flex justify-end")) {
project.zipCode
}
}
}
}
} }
.attributes(.class("border-b border-gray-200"))
Row { ProjectForm(dismiss: true, project: project)
Label("Address")
span { project.streetAddress }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("City")
span { project.city }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("State")
span { project.state }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("Zip")
span { project.zipCode }
}
} }
ProjectForm(dismiss: true, project: project)
} }
} }

View File

@@ -5,6 +5,8 @@ import Styleguide
struct ProjectForm: HTML, Sendable { struct ProjectForm: HTML, Sendable {
static let id = "projectForm"
let project: Project? let project: Project?
let dismiss: Bool let dismiss: Bool
@@ -16,14 +18,19 @@ struct ProjectForm: HTML, Sendable {
self.project = project self.project = project
} }
var route: String {
SiteRoute.View.router.path(for: .project(.index))
.appendingPath(project?.id)
}
var body: some HTML { var body: some HTML {
ModalForm(id: "projectForm", dismiss: dismiss) { ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" } h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" }
form( form(
.class("space-y-4 p-4"), .class("grid grid-cols-1 gap-4"),
project == nil project == nil
? .hx.post(route: .project(.index)) ? .hx.post(route)
: .hx.patch(route: .project(.index)), : .hx.patch(route),
.hx.target("body"), .hx.target("body"),
.hx.swap(.outerHTML) .hx.swap(.outerHTML)
) { ) {
@@ -31,36 +38,54 @@ struct ProjectForm: HTML, Sendable {
input(.class("hidden"), .name("id"), .value("\(project.id)")) input(.class("hidden"), .name("id"), .value("\(project.id)"))
} }
div { LabeledInput(
label(.for("name")) { "Name" } "Name",
Input(id: "name", placeholder: "Name") .name("name"),
.attributes(.type(.text), .required, .autofocus, .value(project?.name)) .type(.text),
} .value(project?.name),
div { .placeholder("Project Name"),
label(.for("streetAddress")) { "Address" } .required,
Input(id: "streetAddress", placeholder: "Street Address") .autofocus
.attributes(.type(.text), .required, .value(project?.streetAddress)) )
}
div {
label(.for("city")) { "City" }
Input(id: "city", placeholder: "City")
.attributes(.type(.text), .required, .value(project?.city))
}
div {
label(.for("state")) { "State" }
Input(id: "state", placeholder: "State")
.attributes(.type(.text), .required, .value(project?.state))
}
div {
label(.for("zipCode")) { "Zip" }
Input(id: "zipCode", placeholder: "Zip code")
.attributes(.type(.text), .required, .value(project?.zipCode))
}
div(.class("flex mt-6")) { LabeledInput(
SubmitButton() "Address",
.attributes(.class("btn-block")) .name("streetAddress"),
} .type(.text),
.value(project?.streetAddress),
.placeholder("Street Address"),
.required
)
LabeledInput(
"City",
.name("city"),
.type(.text),
.value(project?.city),
.placeholder("City"),
.required
)
LabeledInput(
"State",
.name("state"),
.type(.text),
.value(project?.state),
.placeholder("State"),
.required
)
LabeledInput(
"Zip",
.name("zipCode"),
.type(.text),
.value(project?.zipCode),
.placeholder("Zip Code"),
.required
)
SubmitButton()
.attributes(.class("btn-block my-6"))
} }
} }
} }

View File

@@ -2,154 +2,252 @@ import DatabaseClient
import Dependencies import Dependencies
import Elementary import Elementary
import ElementaryHTMX import ElementaryHTMX
import Logging
import ManualDClient
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// TODO: Make view async and load based on the active tab. enum ProjectViewValue {
@TaskLocal static var projectID = Project.ID(0)
}
struct ProjectView: HTML, Sendable { struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
@Dependency(\.database) var database
let projectID: Project.ID let projectID: Project.ID
let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
let inner: Inner
let completedSteps: Project.CompletedSteps
init( init(
projectID: Project.ID, projectID: Project.ID,
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab,
completedSteps: Project.CompletedSteps,
@HTMLBuilder content: () -> Inner
) { ) {
self.projectID = projectID self.projectID = projectID
self.activeTab = activeTab self.activeTab = activeTab
self.inner = content()
self.completedSteps = completedSteps
} }
var body: some HTML { var body: some HTML {
div { div(.class("drawer lg:drawer-open h-full")) {
div(.class("flex flex-row")) { input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
Sidebar(active: activeTab, projectID: projectID)
main(.class("flex flex-col h-screen w-full px-6 py-10")) { div(.class("drawer-content overflow-auto")) {
switch self.activeTab { Navbar(sidebarToggle: true)
case .project: div(.class("p-4")) {
if let project = try await database.projects.get(projectID) { inner
ProjectDetail(project: project) .environment(ProjectViewValue.$projectID, projectID)
} else { }
div { }
"FIX ME!"
} Sidebar(
active: activeTab,
projectID: projectID,
completedSteps: completedSteps
)
}
}
}
extension ProjectView {
struct Sidebar: HTML {
let active: SiteRoute.View.ProjectRoute.DetailRoute.Tab
let projectID: Project.ID
let completedSteps: Project.CompletedSteps
var body: some HTML {
div(.class("drawer-side is-drawer-close:overflow-visible grow")) {
label(
.for("my-drawer-1"), .init(name: "aria-label", value: "close sidebar"),
.class("drawer-overlay")
) {}
div(
.class(
"""
flex grow h-full flex-col items-start bg-base-300 text-base-content
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]
"""
)
) {
ul(.class("w-full grow")) {
li(.class("flex w-full")) {
row(
title: "Project",
icon: .mapPin,
route: .project(.detail(projectID, .index)),
isComplete: true
)
.attributes(.data("active", value: "true"), when: active == .project)
} }
case .rooms:
try await RoomsView(
projectID: projectID,
rooms: database.rooms.fetch(projectID),
sensibleHeatRatio: database.projects.getSensibleHeatRatio(projectID)
)
case .effectiveLength: li(.class("w-full")) {
try await EffectiveLengthsView( row(
effectiveLengths: database.effectiveLength.fetch(projectID) title: "Rooms",
) icon: .doorClosed,
case .frictionRate: route: .project(.detail(projectID, .rooms(.index))),
try await FrictionRateView( isComplete: completedSteps.rooms
equipmentInfo: database.equipment.fetch(projectID), )
componentLosses: database.componentLoss.fetch(projectID), projectID: projectID) .attributes(.data("active", value: "true"), when: active == .rooms)
case .ductSizing: }
div { "FIX ME!" }
li(.class("flex w-full")) {
row(
title: "Equipment",
icon: .fan,
route: .project(.detail(projectID, .equipment(.index))),
isComplete: completedSteps.equipmentInfo
)
.attributes(.data("active", value: "true"), when: active == .equipment)
}
li(.class("w-full")) {
// Tooltip("Equivalent Lengths", position: .right) {
row(
title: "T.E.L.",
icon: .rulerDimensionLine,
route: .project(.detail(projectID, .equivalentLength(.index))),
isComplete: completedSteps.equivalentLength
)
.attributes(.data("active", value: "true"), when: active == .equivalentLength)
// }
}
li(.class("w-full")) {
row(
title: "Friction Rate",
icon: .squareFunction,
route: .project(.detail(projectID, .frictionRate(.index))),
isComplete: completedSteps.frictionRate
)
.attributes(.data("active", value: "true"), when: active == .frictionRate)
}
li(.class("w-full")) {
row(
title: "Duct Sizes",
icon: .wind,
route: .project(.detail(projectID, .ductSizing(.index))),
isComplete: false,
hideIsComplete: true
)
.attributes(.data("active", value: "true"), when: active == .ductSizing)
}
} }
} }
} }
} }
}
}
// TODO: Update to use DaisyUI drawer. // TODO: Use SiteRoute.View routes as href.
struct Sidebar: HTML { private func row(
title: String,
let active: SiteRoute.View.ProjectRoute.DetailRoute.Tab icon: SVG.Key,
let projectID: Project.ID href: String,
isComplete: Bool,
var body: some HTML { hideIsComplete: Bool = false
aside( ) -> some HTML<HTMLTag.button> {
.class( button(
""" .class(
h-screen sticky top-0 max-w-[280px] flex-none """
border-r-2 border-gray-200 w-full gap-1 py-2 border-b-1 border-gray-200
shadow-lg hover:bg-neutral data-active:bg-neutral
""" hover:text-white data-active:text-white
) is-drawer-open:flex is-drawer-open:space-x-4
) { is-drawer-close:grid-cols-1
"""
div(.class("flex")) { ),
// TODO: Move somewhere outside of the sidebar. .hx.get(href),
button( .hx.pushURL(true),
.class("w-full btn btn-secondary"), .hx.target("body"),
.hx.get(route: .project(.index)), .hx.swap(.outerHTML)
.hx.target("body"), ) {
.hx.pushURL(true), div(
.hx.swap(.outerHTML), .class(
"""
w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1
"""
)
) { ) {
"< All Projects" div(
.class(
"""
items-center
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2
"""
)
) {
div(.class("flex items-center justify-center")) {
SVG(icon)
}
.attributes(.class("is-drawer-close:text-green-400"), when: isComplete)
.attributes(.class("is-drawer-close:text-error"), when: !isComplete && !hideIsComplete)
div(.class("flex items-center justify-center")) {
span { title }
}
}
if !hideIsComplete {
div(.class("flex grow justify-end items-end is-drawer-close:hidden")) {
if isComplete {
SVG(.badgeCheck)
} else {
SVG(.ban)
}
}
.attributes(.class("text-green-400"), when: isComplete)
.attributes(.class("text-error"), when: !isComplete)
}
} }
} }
Row {
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
.attributes(.class("p-4"))
row(
title: "Project",
icon: .mapPin,
route: .project(.detail(projectID, .index(tab: .project)))
)
.attributes(.data("active", value: active == .project ? "true" : "false"))
row(title: "Rooms", icon: .doorClosed, route: .project(.detail(projectID, .rooms(.index))))
.attributes(.data("active", value: active == .rooms ? "true" : "false"))
row(title: "Equivalent Lengths", icon: .rulerDimensionLine, route: .effectiveLength(.index))
.attributes(.data("active", value: active == .effectiveLength ? "true" : "false"))
row(
title: "Friction Rate",
icon: .squareFunction,
route: .project(.detail(projectID, .frictionRate(.index)))
)
.attributes(.data("active", value: active == .frictionRate ? "true" : "false"))
row(title: "Duct Sizes", icon: .wind, href: "#")
.attributes(.data("active", value: active == .ductSizing ? "true" : "false"))
} }
}
// TODO: Use SiteRoute.View routes as href. private func row(
private func row( title: String,
title: String, icon: SVG.Key,
icon: Icon.Key, route: SiteRoute.View,
href: String isComplete: Bool,
) -> some HTML<HTMLTag.a> { hideIsComplete: Bool = false
a( ) -> some HTML<HTMLTag.button> {
.class( row(
""" title: title, icon: icon, href: SiteRoute.View.router.path(for: route),
flex w-full items-center gap-4 isComplete: isComplete, hideIsComplete: hideIsComplete
hover:bg-gray-300 hover:text-gray-800 )
data-[active=true]:bg-gray-300 data-[active=true]:text-gray-800
px-4 py-2
"""
),
.href(href)
) {
Icon(icon)
span(.class("text-xl")) {
title
}
} }
} }
}
private func row(
title: String, extension ManualDClient {
icon: Icon.Key,
route: SiteRoute.View func frictionRate(
) -> some HTML<HTMLTag.a> { equipmentInfo: EquipmentInfo?,
row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route)) 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

@@ -17,25 +17,20 @@ struct ProjectsTable: HTML, Sendable {
var body: some HTML { var body: some HTML {
div { div {
Row { Navbar(sidebarToggle: false)
h1(.class("text-2xl font-bold")) { "Projects" } div(.class("m-6")) {
div( PageTitleRow {
.class("tooltip tooltip-left"), PageTitle { "Projects" }
.data("tip", value: "Add project") Tooltip("Add project") {
) { PlusButton()
button( .attributes(
.class("btn btn-primary w-[40px] text-2xl"), .class("btn-primary"),
.hx.get(route: .project(.form(dismiss: false))), .showModal(id: ProjectForm.id)
.hx.target("#projectForm"), )
.hx.swap(.outerHTML)
) {
"+"
} }
} }
} .attributes(.class("pb-6"))
.attributes(.class("pb-6"))
div(.class("overflow-x-auto rounded-box border")) {
table(.class("table table-zebra")) { table(.class("table table-zebra")) {
thead { thead {
tr { tr {
@@ -60,24 +55,43 @@ extension ProjectsTable {
struct Rows: HTML, Sendable { struct Rows: HTML, Sendable {
let projects: Page<Project> let projects: Page<Project>
func tooltipPosition(_ n: Int) -> TooltipPosition {
if projects.metadata.page == 1 && projects.items.count == 1 {
return .left
} else if n == (projects.items.count - 1) {
return .left
} else {
return .bottom
}
}
var body: some HTML { var body: some HTML {
for project in projects.items { for (n, project) in projects.items.enumerated() {
tr(.id("\(project.id)")) { tr(.id("\(project.id)")) {
td { DateView(project.createdAt) } td { DateView(project.createdAt) }
td { "\(project.name)" } td { "\(project.name)" }
td { "\(project.streetAddress)" } td { "\(project.streetAddress)" }
td { td {
div(.class("flex justify-end space-x-6")) { div(.class("flex justify-end space-x-6")) {
TrashButton() div(.class("join")) {
.attributes( Tooltip("Delete project", position: tooltipPosition(n)) {
.hx.delete(route: .project(.delete(id: project.id))), TrashButton()
.hx.confirm("Are you sure?"), .attributes(
.hx.target("closest tr") .class("join-item btn-ghost"),
) .hx.delete(route: .project(.delete(id: project.id))),
a( .hx.confirm("Are you sure?"),
.class("btn btn-success dark:text-white"), .hx.target("closest tr")
.href(route: .project(.detail(project.id, .index()))) )
) { ">" } }
Tooltip("View project", position: tooltipPosition(n)) {
a(
.class("join-item btn btn-success btn-ghost"),
.href(route: .project(.detail(project.id, .rooms(.index))))
) {
SVG(.chevronRight)
}
}
}
} }
} }
} }

View File

@@ -8,22 +8,41 @@ import Styleguide
// TODO: Need to hold the project ID in hidden input field. // TODO: Need to hold the project ID in hidden input field.
struct RoomForm: HTML, Sendable { struct RoomForm: HTML, Sendable {
static let id = "roomForm" static func id(_ room: Room? = nil) -> String {
let baseId = "roomForm"
guard let room else { return baseId }
return baseId.appending("_\(room.id.idString)")
}
let dismiss: Bool let dismiss: Bool
let projectID: Project.ID let projectID: Project.ID
let room: Room? let room: Room?
init(
dismiss: Bool,
projectID: Project.ID,
room: Room? = nil
) {
self.dismiss = dismiss
self.projectID = projectID
self.room = room
}
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .rooms(.index)))
)
.appendingPath(room?.id)
}
var body: some HTML { var body: some HTML {
ModalForm(id: Self.id, dismiss: dismiss) { ModalForm(id: Self.id(room), dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6")) { "Room" } h1(.class("text-3xl font-bold pb-6")) { "Room" }
// TODO: Use htmx here.
form( form(
.class("modal-backdrop"), .class("grid grid-cols-1 gap-4"),
.init(name: "method", value: "dialog"),
room == nil room == nil
? .hx.post(route: .project(.detail(projectID, .rooms(.index)))) ? .hx.post(route)
: .hx.patch(route: .project(.detail(projectID, .rooms(.index)))), : .hx.patch(route),
.hx.target("body"), .hx.target("body"),
.hx.swap(.outerHTML) .hx.swap(.outerHTML)
) { ) {
@@ -34,37 +53,56 @@ struct RoomForm: HTML, Sendable {
input(.class("hidden"), .name("id"), .value("\(id)")) input(.class("hidden"), .name("id"), .value("\(id)"))
} }
div { LabeledInput(
label(.for("name")) { "Name:" } "Name",
Input(id: "name", placeholder: "Room Name") .name("name"),
.attributes(.type(.text), .required, .autofocus, .value(room?.name)) .type(.text),
} .placeholder("Name"),
div { .required,
label(.for("heatingLoad")) { "Heating Load:" } .autofocus,
Input(id: "heatingLoad", placeholder: "Heating Load") .value(room?.name)
.attributes(.type(.number), .required, .min("0"), .value(room?.heatingLoad)) )
}
div { LabeledInput(
label(.for("coolingTotal")) { "Cooling Total:" } "Heating Load",
Input(id: "coolingTotal", placeholder: "Cooling Total") .name("heatingLoad"),
.attributes(.type(.number), .required, .min("0"), .value(room?.coolingTotal)) .type(.number),
} .placeholder("1234"),
div { .required,
label(.for("coolingSensible")) { "Cooling Sensible:" } .min("0"),
Input(id: "coolingSensible", placeholder: "Cooling Sensible (Optional)") .value(room?.heatingLoad)
.attributes(.type(.number), .min("0"), .value(room?.coolingSensible)) )
}
div(.class("pb-6")) { LabeledInput(
label(.for("registerCount")) { "Registers:" } "Cooling Total",
Input(id: "registerCount", placeholder: "Register Count") .name("coolingTotal"),
.attributes( .type(.number),
.type(.number), .required, .min("0"), .placeholder("1234"),
.value("\(room != nil ? room!.registerCount : 1)"), .required,
) .min("0"),
} .value(room?.coolingTotal)
div(.class("flex justify-end space-x-4")) { )
SubmitButton()
} LabeledInput(
"Cooling Sensible",
.name("coolingSensible"),
.type(.number),
.placeholder("1234 (Optional)"),
.min("0"),
.value(room?.coolingSensible)
)
LabeledInput(
"Registers",
.name("registerCount"),
.type(.number),
.min("1"),
.required,
.value(room?.registerCount ?? 1)
)
SubmitButton()
.attributes(.class("btn-block"))
} }
} }
} }

View File

@@ -5,80 +5,112 @@ import Foundation
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// TODO: Calculate rooms sensible based on project wide SHR.
struct RoomsView: HTML, Sendable { struct RoomsView: HTML, Sendable {
let projectID: Project.ID @Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [Room] let rooms: [Room]
let sensibleHeatRatio: Double? let sensibleHeatRatio: Double?
var body: some HTML { var body: some HTML {
div { div(.class("flex w-full flex-col")) {
Row { PageTitleRow {
h1(.class("text-2xl font-bold")) { "Room Loads" } div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
div(
.class("tooltip tooltip-left"), div(.class("col-span-2")) {
.data("tip", value: "Add room") PageTitle { "Room Loads" }
) { }
button(
.showModal(id: RoomForm.id), div(.class("flex justify-end grow")) {
.class("btn btn-primary w-[40px] text-2xl") Tooltip("Project wide sensible heat ratio", position: .left) {
) { button(
"+" .class(
"""
btn btn-primary text-lg font-bold py-2
"""
),
.showModal(id: SHRForm.id)
) {
div(.class("flex grow justify-end items-end space-x-4")) {
span {
"Sensible Heat Ratio"
}
if let sensibleHeatRatio {
Badge(number: sensibleHeatRatio)
} else {
Badge { "set" }
}
}
}
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
}
div(.class("flex items-end space-x-4 font-bold")) {
span(.class("text-lg")) { "Heating Total" }
Badge(number: rooms.heatingTotal, 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)
.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)
.attributes(.class("badge-info"))
} }
} }
} }
.attributes(.class("pb-6"))
div(.class("border rounded-lg mb-6")) { SHRForm(
Row { sensibleHeatRatio: sensibleHeatRatio,
div(.class("space-x-6")) { dismiss: sensibleHeatRatio != nil
Label("Sensible Heat Ratio") )
if let sensibleHeatRatio {
Number(sensibleHeatRatio) table(.class("table table-zebra text-lg"), .id("roomsTable")) {
thead {
tr(.class("text-lg font-bold")) {
th { "Name" }
th {
div(.class("flex justify-center")) {
"Heating Load"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Total"
}
}
th {
div(.class("flex justify-center")) {
"Cooling Sensible"
}
}
th {
div(.class("flex justify-center")) {
"Register Count"
}
}
th {
div(.class("flex justify-end me-2")) {
Tooltip("Add Room") {
PlusButton()
.attributes(
.class("btn-primary mx-auto"),
.showModal(id: RoomForm.id())
)
.attributes(.class("tooltip-left"))
}
}
} }
} }
EditButton()
.attributes(.showModal(id: SHRForm.id))
} }
.attributes(.class("m-4")) tbody {
for room in rooms {
SHRForm(projectID: projectID, sensibleHeatRatio: sensibleHeatRatio) RoomRow(room: room, shr: sensibleHeatRatio)
}
div(.class("overflow-x-auto rounded-box border")) {
table(.class("table table-zebra"), .id("roomsTable")) {
thead {
tr {
th { Label("Name") }
th { Label("Heating Load") }
th { Label("Cooling Total") }
th { Label("Register Count") }
th {}
}
}
tbody {
div(.id("rooms")) {
for room in rooms {
RoomRow(room: room)
}
}
// TOTALS
tr(.class("font-bold text-xl")) {
td { Label("Total") }
td {
Number(rooms.heatingTotal)
.attributes(.class("badge badge-outline badge-error badge-xl"))
}
td {
Number(rooms.coolingTotal)
.attributes(
.class("badge badge-outline badge-success badge-xl"))
}
td {}
td {}
}
} }
} }
} }
@@ -87,44 +119,77 @@ struct RoomsView: HTML, Sendable {
} }
public struct RoomRow: HTML, Sendable { public struct RoomRow: HTML, Sendable {
let room: Room let room: Room
let shr: Double
var coolingSensible: Double {
guard let value = room.coolingSensible else {
return room.coolingTotal * shr
}
return value
}
init(room: Room, shr: Double?) {
self.room = room
self.shr = shr ?? 1.0
}
public var body: some HTML { public var body: some HTML {
tr(.id("\(room.id)")) { tr(.id("roomRow_\(room.id.idString)")) {
td { room.name } td { room.name }
td { td {
Number(room.heatingLoad) div(.class("flex justify-center")) {
.attributes(.class("text-error")) Number(room.heatingLoad, digits: 0)
} // .attributes(.class("text-error"))
td {
Number(room.coolingTotal)
.attributes(.class("text-success"))
}
// FIX: Add cooling sensible.
td {
Number(room.registerCount)
}
td {
div(.class("flex justify-end space-x-6")) {
TrashButton()
.attributes(
.hx.delete(
route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))),
.hx.target("closest tr"),
.hx.confirm("Are you sure?")
)
EditButton()
.attributes(
.hx.get(
route: .project(
.detail(room.projectID, .rooms(.form(id: room.id, dismiss: false)))
)
),
.hx.target("#roomForm"),
.hx.swap(.outerHTML)
)
} }
} }
td {
div(.class("flex justify-center")) {
Number(room.coolingTotal, digits: 0)
// .attributes(.class("text-success"))
}
}
td {
div(.class("flex justify-center")) {
Number(coolingSensible, digits: 0)
// .attributes(.class("text-info"))
}
}
td {
div(.class("flex justify-center")) {
Number(room.registerCount)
}
}
td {
div(.class("flex justify-end")) {
div(.class("join")) {
Tooltip("Delete room", position: .bottom) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(
route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))),
.hx.target("closest tr"),
.hx.confirm("Are you sure?")
)
}
Tooltip("Edit room", position: .bottom) {
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RoomForm.id(room))
)
}
}
}
RoomForm(
dismiss: true,
projectID: room.projectID,
room: room
)
}
} }
} }
} }
@@ -132,27 +197,41 @@ struct RoomsView: HTML, Sendable {
struct SHRForm: HTML, Sendable { struct SHRForm: HTML, Sendable {
static let id = "shrForm" static let id = "shrForm"
let projectID: Project.ID @Environment(ProjectViewValue.$projectID) var projectID
let sensibleHeatRatio: Double? let sensibleHeatRatio: Double?
let dismiss: Bool
var route: String {
SiteRoute.View.router
.path(for: .project(.detail(projectID, .rooms(.index))))
.appendingPath("update-shr")
}
var body: some HTML { var body: some HTML {
ModalForm(id: Self.id, dismiss: true) { ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-xl font-bold mb-6")) {
"Sensible Heat Ratio"
}
form( form(
.class("space-y-6"), .class("grid grid-cols-1 gap-4"),
.hx.patch("/projects/\(projectID)/rooms/update-shr"), .hx.patch(route),
.hx.target("body"), .hx.target("body"),
.hx.swap(.outerHTML) .hx.swap(.outerHTML)
) { ) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)")) input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
div { LabeledInput(
label(.for("sensibleHeatRatio")) { "Sensible Heat Ratio" } "SHR",
Input(id: "sensibleHeatRatio", placeholder: "Sensible Heat Ratio") .name("sensibleHeatRatio"),
.attributes(.min("0"), .max("1"), .step("0.01"), .value(sensibleHeatRatio)) .type(.number),
} .value(sensibleHeatRatio),
div { .placeholder("0.83"),
SubmitButton() .min("0"),
.attributes(.class("btn-block")) .max("1"),
} .step("0.01"),
.autofocus
)
SubmitButton()
.attributes(.class("btn-block my-6"))
} }
} }
} }
@@ -167,4 +246,13 @@ extension Array where Element == Room {
var coolingTotal: Double { var coolingTotal: Double {
reduce(into: 0) { $0 += $1.coolingTotal } 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
}
}
} }

View File

@@ -0,0 +1,31 @@
import Dependencies
import Elementary
import Foundation
import ManualDCore
import Styleguide
struct TestPage: HTML, Sendable {
let trunks: [DuctSizing.TrunkContainer]
let rooms: [DuctSizing.RoomContainer]
var body: some HTML {
div(.class("overflow-auto")) {
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
Row {
h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
PlusButton()
.attributes(
.class("me-6"),
.showModal(id: TrunkSizeForm.id())
)
}
.attributes(.class("mt-6"))
div(.class("divider -mt-2")) {}
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
}
}
}

View File

@@ -25,30 +25,12 @@ struct LoginForm: HTML, Sendable {
input(.class("hidden"), .name("next"), .value(next)) input(.class("hidden"), .name("next"), .value(next))
} }
if style == .signup {
div {
label(.class("input validator w-full")) {
SVG(.user)
input(
.type(.text), .required, .placeholder("Username"),
.name("username"), .id("username"),
.minlength("3"), .pattern(.username)
)
}
div(.class("validator-hint hidden")) {
"Enter valid username"
br()
"Must be at least 3 characters"
}
}
}
div { div {
label(.class("input validator w-full")) { label(.class("input validator w-full")) {
SVG(.email) SVG(.email)
input( input(
.type(.email), .placeholder("Email"), .required, .type(.email), .placeholder("Email"), .required,
.name("email"), .id("email"), .name("email"), .id("email"), .autofocus
) )
} }
div(.class("validator-hint hidden")) { "Enter valid email address." } div(.class("validator-hint hidden")) { "Enter valid email address." }

View File

@@ -0,0 +1,155 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct UserProfileForm: HTML, Sendable {
static func id(_ profile: User.Profile?) -> String {
let base = "userProfileForm"
guard let profile else { return base }
return "\(base)_\(profile.id.idString)"
}
let userID: User.ID
let profile: User.Profile?
let dismiss: Bool
let signup: Bool
init(
userID: User.ID,
profile: User.Profile? = nil,
dismiss: Bool,
signup: Bool = false
) {
self.userID = userID
self.profile = profile
self.dismiss = dismiss
self.signup = signup
}
var route: String {
guard !signup else {
return SiteRoute.View.router.path(for: .signup(.index))
.appendingPath("profile")
}
return SiteRoute.View.router.path(for: .user(.profile(.index)))
.appendingPath(profile?.id)
}
var body: some HTML {
ModalForm(id: Self.id(profile), closeButton: dismiss, dismiss: dismiss) {
h1(.class("text-xl font-bold pb-6")) { "Profile" }
form(
.class("grid grid-cols-1 gap-4 p-4"),
profile == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
if let profile {
input(.class("hidden"), .name("id"), .value(profile.id))
}
input(.class("hidden"), .name("userID"), .value(userID))
label(.class("input w-full")) {
span(.class("label")) { "First Name" }
input(.name("firstName"), .value(profile?.firstName), .required, .autofocus)
}
label(.class("input w-full")) {
span(.class("label")) { "Last Name" }
input(.name("lastName"), .value(profile?.lastName), .required)
}
label(.class("input w-full")) {
span(.class("label")) { "Company" }
input(.name("companyName"), .value(profile?.companyName), .required)
}
label(.class("input w-full")) {
span(.class("label")) { "Address" }
input(.name("streetAddress"), .value(profile?.streetAddress), .required)
}
label(.class("input w-full")) {
span(.class("label")) { "City" }
input(.name("city"), .value(profile?.city), .required)
}
label(.class("input w-full")) {
span(.class("label")) { "State" }
input(.name("state"), .value(profile?.state), .required)
}
label(.class("input w-full")) {
span(.class("label")) { "Zip" }
input(.name("zipCode"), .value(profile?.zipCode), .required)
}
div(.class("dropdown dropdown-top")) {
div(.class("input btn m-1 w-full"), .tabindex(0), .role(.init(rawValue: "button"))) {
"Theme"
SVG(.chevronDown)
}
ul(
.tabindex(-1),
.class("dropdown-content bg-base-300 rounded-box z-1 p-2 shadow-2xl")
) {
li {
input(
.type(.radio),
.name("theme"),
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
.init(name: "aria-label", value: "Default"),
.value("default")
)
.attributes(.checked, when: profile?.theme == .default)
}
li {
span(.class("text-sm font-bold text-gray-400")) {
"Light"
}
}
for theme in Theme.lightThemes {
li {
input(
.type(.radio),
.name("theme"),
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
.init(name: "aria-label", value: "\(theme.rawValue.capitalized)"),
.value(theme.rawValue)
)
.attributes(.checked, when: profile?.theme == theme)
}
}
li {
span(.class("text-sm font-bold text-gray-400")) {
"Dark"
}
}
for theme in Theme.darkThemes {
li {
input(
.type(.radio),
.name("theme"),
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
.init(name: "aria-label", value: "\(theme.rawValue.capitalized)"),
.value(theme.rawValue)
)
.attributes(.checked, when: profile?.theme == theme)
}
}
}
}
SubmitButton()
.attributes(.class("btn-block"))
}
}
}
}

View File

@@ -0,0 +1,57 @@
import Elementary
import ManualDCore
import Styleguide
struct UserView: HTML, Sendable {
let user: User
let profile: User.Profile?
var body: some HTML {
div {
Navbar(sidebarToggle: false, userProfile: false)
div(.class("p-4")) {
Row {
h1(.class("text-2xl font-bold")) { "Account" }
EditButton()
.attributes(.showModal(id: UserProfileForm.id(profile)))
}
if let profile {
table(.class("table table-zebra border rounded-lg")) {
tr {
td { Label("Name") }
td { "\(profile.firstName) \(profile.lastName)" }
}
tr {
td { Label("Company") }
td { profile.companyName }
}
tr {
td { Label("Street Address") }
td { profile.streetAddress }
}
tr {
td { Label("City") }
td { profile.city }
}
tr {
td { Label("State") }
td { profile.state }
}
tr {
td { Label("Zip Code") }
td { profile.zipCode }
}
tr {
td { Label("Theme") }
td { profile.theme?.rawValue ?? "" }
}
}
}
UserProfileForm(userID: user.id, profile: profile, dismiss: true)
}
}
}
}

View File

@@ -72,4 +72,33 @@ struct ProjectRouteTests {
let route = try router.parse(&request) let route = try router.parse(&request)
#expect(route == .project(.index)) #expect(route == .project(.index))
} }
@Test
func formData() throws {
let p = Body {
FormData {
Optionally {
Field("id", default: nil) { EffectiveLength.ID.parser() }
}
Field("name", .string)
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
Many {
Field("straightLengths") {
Int.parser()
}
}
}
.map(.memberwise(SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.init))
}
var request = URLRequestData(
body: .init(
"name=Test&type=supply&straightLengths=20&straightLengths=10"
.utf8
)
)
let value = try p.parse(&request)
print(value)
#expect(value.straightLengths == [20, 10])
}
} }

View File

@@ -1,4 +1,5 @@
docker_image := "manuald" docker_image := "ductcalc"
docker_tag := "latest"
install-deps: install-deps:
@curl -sL daisyui.com/fast | bash @curl -sL daisyui.com/fast | bash
@@ -9,8 +10,8 @@ run-css:
run: run:
@swift run App serve --log debug @swift run App serve --log debug
build-docker: build-docker file="docker/Dockerfile":
@podman build -f docker/Dockerfile.dev -t {{docker_image}}:dev . @podman build -f {{file}} -t {{docker_image}}:{{docker_tag}} .
run-dev: run-docker:
@podman run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:dev @podman run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:{{docker_tag}}