50 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
8fb313fddc feat: Adds sensible heat ratio for projects, adds initial view / forms to the rooms tab. 2026-01-06 12:19:14 -05:00
5fcc5b88fa feat: Better modal form using dialog, some forms still need updated to use it effectively. 2026-01-06 10:12:48 -05:00
fc12e47b5c feat: Working on adding updates to project form, it's currently not loading an existing project. 2026-01-05 17:04:25 -05:00
4c8a23be94 feat: Adds update and delete routes to room. 2026-01-05 15:59:23 -05:00
fb7cf9905c feat: Adds update route to equipment info, reorganizes views. 2026-01-05 11:27:20 -05:00
55a3adde25 WIP: Moves friction rate route to be part of project detail routes. 2026-01-05 09:01:49 -05:00
4aca134abd WIP: 2026-01-05 07:38:25 -05:00
f159c3ab75 feat: adds next route to login. 2026-01-04 09:30:14 -05:00
a61c772f7b WIP: Exploring different routes. 2026-01-03 19:03:04 -05:00
9f63b96c80 WIP: Working rooms table and form for project. 2026-01-03 17:02:21 -05:00
1aeb6144d5 WIP: Changes main page to not include sidebar, that moves to project view. 2026-01-03 16:24:53 -05:00
102 changed files with 8398 additions and 5472 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

@@ -67,6 +67,7 @@ let package = Package(
name: "ManualDCore",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Fluent", package: "fluent"),
.product(name: "URLRouting", package: "swift-url-routing"),
.product(name: "CasePaths", package: "swift-case-paths"),
]
@@ -103,6 +104,8 @@ let package = Package(
.target(
name: "ViewController",
dependencies: [
.target(name: "DatabaseClient"),
.target(name: "ManualDClient"),
.target(name: "ManualDCore"),
.target(name: "Styleguide"),
.product(name: "Dependencies", package: "swift-dependencies"),

View File

@@ -1,5 +1,5 @@
@import "tailwindcss";
@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):
try await database.projects.delete(id)
return nil
case .detail(let id, let route):
switch route {
case .completedSteps:
// FIX:
fatalError()
}
case .get(let id):
guard let project = try await database.projects.get(id) else {
logger.error("Project not found for id: \(id)")

View File

@@ -6,18 +6,17 @@ import Vapor
private let viewRouteMiddleware: [any Middleware] = [
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware(path: "/login"),
User.redirectMiddleware { req in
"/login?next=\(req.url.string)"
},
]
extension SiteRoute.View {
var middleware: [any Middleware]? {
switch self {
case .project,
.frictionRate,
.effectiveLength,
.room:
case .project, .user:
return viewRouteMiddleware
case .login, .signup:
case .login, .signup, .test:
return nil
}
}

View File

@@ -10,8 +10,11 @@ extension DatabaseClient {
public var create:
@Sendable (ComponentPressureLoss.Create) async throws -> ComponentPressureLoss
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 update:
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
ComponentPressureLoss
}
}
@@ -34,18 +37,26 @@ extension DatabaseClient.ComponentLoss {
try await model.delete(on: database)
},
fetch: { projectID in
guard
let model = try await ComponentLossModel.query(on: database)
.filter("projectID", .equal, projectID)
.first()
else {
throw NotFoundError()
}
return try model.toDTO()
try await ComponentLossModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.map { try $0.toDTO() }
},
get: { id in
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()
}
)
}
@@ -71,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 {
struct Migrate: AsyncMigration {
let name = "CreateComponentLoss"
@@ -82,8 +111,10 @@ extension ComponentPressureLoss {
.field("value", .double, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.unique(on: "projectID", "name")
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
// .unique(on: "projectID", "name")
.create()
}
@@ -143,4 +174,13 @@ final class ComponentLossModel: Model, @unchecked Sendable {
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

@@ -9,8 +9,11 @@ extension DatabaseClient {
public struct EffectiveLengthClient: Sendable {
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
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 update:
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
}
}
@@ -31,18 +34,41 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
try await model.delete(on: database)
},
fetch: { projectID in
guard
let model = try await EffectiveLengthModel.query(on: database)
.filter("projectID", .equal, projectID)
.first()
else {
throw NotFoundError()
}
try await EffectiveLengthModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.all()
.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
)
return try model.toDTO()
},
get: { id in
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()
}
)
}
@@ -82,7 +108,9 @@ extension EffectiveLength {
.field("groups", .data)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name", "type")
.create()
}
@@ -157,4 +185,19 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
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,6 +11,8 @@ extension DatabaseClient {
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
public var update:
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
}
}
@@ -38,12 +40,23 @@ extension DatabaseClient.Equipment {
.filter("projectID", .equal, projectId)
.first()
else {
throw NotFoundError()
return nil
}
return try model.toDTO()
},
get: { id in
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await EquipmentModel.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()
}
)
}
@@ -77,6 +90,30 @@ extension EquipmentInfo.Create {
}
}
extension EquipmentInfo.Update {
var hasUpdates: Bool {
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
}
func validate() throws(ValidationError) {
if let staticPressure {
guard staticPressure >= 0 else {
throw ValidationError("Equipment info static pressure should be greater than 0.")
}
}
if let heatingCFM {
guard heatingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
if let coolingCFM {
guard coolingCFM >= 0 else {
throw ValidationError("Equipment info heating CFM should be greater than 0.")
}
}
}
}
extension EquipmentInfo {
struct Migrate: AsyncMigration {
@@ -91,7 +128,9 @@ extension EquipmentInfo {
.field("coolingCFM", .int16, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID")
.create()
}
@@ -159,4 +198,16 @@ final class EquipmentModel: Model, @unchecked Sendable {
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: EquipmentInfo.Update) {
if let staticPressure = updates.staticPressure {
self.staticPressure = staticPressure
}
if let heatingCFM = updates.heatingCFM {
self.heatingCFM = heatingCFM
}
if let coolingCFM = updates.coolingCFM {
self.coolingCFM = coolingCFM
}
}
}

View File

@@ -1,5 +1,6 @@
import Foundation
// TODO: Move to ManualDCore
public struct ValidationError: Error {
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 effectiveLength: EffectiveLengthClient
public var users: Users
public var userProfile: UserProfile
public var trunkSizes: TrunkSizes
}
extension DatabaseClient: TestDependencyKey {
@@ -29,7 +31,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
users: .testValue
users: .testValue,
userProfile: .testValue,
trunkSizes: .testValue
)
public static func live(database: any Database) -> Self {
@@ -40,7 +44,9 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database),
componentLoss: .live(database: database),
effectiveLength: .live(database: database),
users: .live(database: database)
users: .live(database: database),
userProfile: .live(database: database),
trunkSizes: .live(database: database)
)
}
}
@@ -60,13 +66,15 @@ extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self(
run: {
[
Project.Migrate(),
User.Migrate(),
User.Token.Migrate(),
Project.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.Migrate(),
EffectiveLength.Migrate(),
DuctSizing.TrunkSize.Migrate(),
]
}
)

View File

@@ -10,7 +10,10 @@ extension DatabaseClient {
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void
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 fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
}
}
@@ -33,6 +36,48 @@ extension DatabaseClient.Projects: TestDependencyKey {
get: { id in
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
guard let model = try await ProjectModel.find(id, on: database) else {
throw NotFoundError()
}
return model.sensibleHeatRatio
},
fetch: { userID, request in
try await ProjectModel.query(on: database)
.sort(\.$createdAt, .descending)
@@ -40,6 +85,17 @@ extension DatabaseClient.Projects: TestDependencyKey {
.filter(\.$user.$id == userID)
.paginate(request)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await ProjectModel.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()
}
)
}
@@ -75,6 +131,53 @@ extension Project.Create {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
extension Project.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Project name should not be empty.")
}
}
if let streetAddress {
guard !streetAddress.isEmpty else {
throw ValidationError("Project street address should not be empty.")
}
}
if let city {
guard !city.isEmpty else {
throw ValidationError("Project city should not be empty.")
}
}
if let state {
guard !state.isEmpty else {
throw ValidationError("Project state should not be empty.")
}
}
if let zipCode {
guard !zipCode.isEmpty else {
throw ValidationError("Project zipCode should not be empty.")
}
}
if let sensibleHeatRatio {
guard sensibleHeatRatio >= 0 else {
throw ValidationError("Project sensible heat ratio should be greater than 0.")
}
guard sensibleHeatRatio <= 1 else {
throw ValidationError("Project sensible heat ratio should be less than 1.")
}
}
}
}
@@ -90,6 +193,7 @@ extension Project {
.field("city", .string, .required)
.field("state", .string, .required)
.field("zipCode", .string, .required)
.field("sensibleHeatRatio", .double)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field("userID", .uuid, .required, .references(UserModel.schema, "id"))
@@ -126,6 +230,9 @@ final class ProjectModel: Model, @unchecked Sendable {
@Field(key: "zipCode")
var zipCode: String
@Field(key: "sensibleHeatRatio")
var sensibleHeatRatio: Double?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -147,6 +254,7 @@ final class ProjectModel: Model, @unchecked Sendable {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
userID: User.ID,
createdAt: Date? = nil,
updatedAt: Date? = nil
@@ -155,9 +263,9 @@ final class ProjectModel: Model, @unchecked Sendable {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
$user.id = userID
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -171,8 +279,32 @@ final class ProjectModel: Model, @unchecked Sendable {
city: city,
state: state,
zipCode: zipCode,
sensibleHeatRatio: sensibleHeatRatio,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Project.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
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 sensibleHeatRatio = updates.sensibleHeatRatio,
sensibleHeatRatio != self.sensibleHeatRatio
{
self.sensibleHeatRatio = sensibleHeatRatio
}
}
}

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,15 +9,19 @@ extension DatabaseClient {
public struct Rooms: Sendable {
public var create: @Sendable (Room.Create) async throws -> Room
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 fetch: @Sendable (Project.ID) 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
}
}
extension DatabaseClient.Rooms: TestDependencyKey {
public static let testValue = Self()
}
extension DatabaseClient.Rooms {
public static func live(database: any Database) -> Self {
.init(
create: { request in
@@ -31,8 +35,53 @@ extension DatabaseClient.Rooms {
}
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
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
},
fetch: { projectID in
try await RoomModel.query(on: database)
.with(\.$project)
.filter(\.$project.$id, .equal, projectID)
.sort(\.$name, .ascending)
.all()
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await RoomModel.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()
},
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()
}
)
}
@@ -62,8 +111,10 @@ extension Room.Create {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
@@ -71,6 +122,37 @@ extension Room.Create {
}
}
extension Room.Update {
func validate() throws(ValidationError) {
if let name {
guard !name.isEmpty else {
throw ValidationError("Room name should not be empty.")
}
}
if let heatingLoad {
guard heatingLoad >= 0 else {
throw ValidationError("Room heating load should not be less than 0.")
}
}
if let coolingTotal {
guard coolingTotal >= 0 else {
throw ValidationError("Room cooling total should not be less than 0.")
}
}
if let coolingSensible {
guard coolingSensible >= 0 else {
throw ValidationError("Room cooling sensible should not be less than 0.")
}
}
if let registerCount {
guard registerCount >= 1 else {
throw ValidationError("Room cooling sensible should not be less than 1.")
}
}
}
}
extension Room {
struct Migrate: AsyncMigration {
let name = "CreateRoom"
@@ -81,9 +163,14 @@ extension Room {
.field("name", .string, .required)
.field("heatingLoad", .double, .required)
.field("coolingTotal", .double, .required)
.field("coolingSensible", .double, .required)
.field("coolingSensible", .double)
.field("registerCount", .int8, .required)
.field("projectID", .uuid, .required, .references(ProjectModel.schema, "id"))
.field("rectangularSizes", .array)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.field(
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
)
.unique(on: "projectID", "name")
.create()
}
@@ -111,11 +198,14 @@ final class RoomModel: Model, @unchecked Sendable {
var coolingTotal: Double
@Field(key: "coolingSensible")
var coolingSensible: Double
var coolingSensible: Double?
@Field(key: "registerCount")
var registerCount: Int
@Field(key: "rectangularSizes")
var rectangularSizes: [DuctSizing.RectangularDuct]?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@@ -132,8 +222,9 @@ final class RoomModel: Model, @unchecked Sendable {
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double,
coolingSensible: Double? = nil,
registerCount: Int,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
projectID: Project.ID
@@ -144,6 +235,7 @@ final class RoomModel: Model, @unchecked Sendable {
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
$project.id = projectID
@@ -155,11 +247,36 @@ final class RoomModel: Model, @unchecked Sendable {
projectID: $project.id,
name: name,
heatingLoad: heatingLoad,
coolingLoad: .init(total: coolingTotal, sensible: coolingSensible),
coolingTotal: coolingTotal,
coolingSensible: coolingSensible,
registerCount: registerCount,
rectangularSizes: rectangularSizes,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: Room.Update) {
if let name = updates.name, name != self.name {
self.name = name
}
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
self.heatingLoad = heatingLoad
}
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
self.coolingTotal = coolingTotal
}
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
self.coolingSensible = coolingSensible
}
if let registerCount = updates.registerCount, registerCount != self.registerCount {
self.registerCount = registerCount
}
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 {
try await database.schema(UserModel.schema)
.id()
.field("username", .string, .required)
.field("email", .string, .required)
.field("password_hash", .string, .required)
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "email", "username")
.unique(on: "email")
.create()
}
@@ -104,6 +103,8 @@ extension User.Token {
.id()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value")
.create()
}
@@ -126,13 +127,10 @@ extension User.Create {
func toModel() throws -> UserModel {
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 {
guard !username.isEmpty else {
throw ValidationError("Username should not be empty.")
}
guard !email.isEmpty else {
throw ValidationError("Email should not be empty")
}
@@ -152,9 +150,6 @@ final class UserModel: Model, @unchecked Sendable {
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "email")
var email: String
@@ -174,12 +169,10 @@ final class UserModel: Model, @unchecked Sendable {
init(
id: UUID? = nil,
username: String,
email: String,
passwordHash: String
) {
self.id = id
self.username = username
self.email = email
self.passwordHash = passwordHash
}
@@ -188,7 +181,6 @@ final class UserModel: Model, @unchecked Sendable {
try .init(
id: requireID(),
email: email,
username: username,
createdAt: createdAt!,
updatedAt: updatedAt!
)
@@ -237,7 +229,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable {
extension User: Authenticatable {}
extension User: SessionAuthenticatable {
public var sessionID: String { username }
public var sessionID: String { email }
}
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
@@ -248,7 +240,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == basic.username)
.filter(\UserModel.$email == basic.username)
.first(),
try user.verifyPassword(basic.password)
else {
@@ -284,7 +276,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
guard
let user = try await UserModel.query(on: request.db)
.filter(\UserModel.$username == sessionID)
.filter(\UserModel.$email == sessionID)
.first()
else {
throw Abort(.unauthorized)

View File

@@ -1,6 +1,51 @@
import Foundation
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 {
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.")
}
// let size = size.rounded(.toNearestOrEven)
switch size {
case 0..<4:
return 4

View File

@@ -25,7 +25,7 @@ extension ManualDClient: DependencyKey {
throw ManualDError(message: "Total Effective Length should be greater than 0.")
}
let totalComponentLosses = request.componentPressureLosses.totalLosses
let totalComponentLosses = request.componentPressureLosses.total
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
@@ -38,23 +38,7 @@ extension ManualDClient: DependencyKey {
},
equivalentRectangularDuct: { request in
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).
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)
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
}
)
}
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 DependenciesMacros
import Logging
import ManualDCore
@DependencyClient
@@ -9,6 +10,146 @@ public struct ManualDClient: Sendable {
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
public var equivalentRectangularDuct:
@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 {
@@ -63,12 +204,12 @@ extension ManualDClient {
public struct FrictionRateRequest: Codable, Equatable, Sendable {
public let externalStaticPressure: Double
public let componentPressureLosses: ComponentPressureLosses
public let componentPressureLosses: [ComponentPressureLoss]
public let totalEffectiveLength: Int
public init(
externalStaticPressure: Double,
componentPressureLosses: ComponentPressureLosses,
componentPressureLosses: [ComponentPressureLoss],
totalEffectiveLength: Int
) {
self.externalStaticPressure = externalStaticPressure

View File

@@ -42,6 +42,35 @@ extension ComponentPressureLoss {
self.name = name
self.value = value
}
// Return's commonly used default component pressure losses.
public static func `default`(projectID: Project.ID) -> [Self] {
[
.init(projectID: projectID, name: "supply-outlet", value: 0.03),
.init(projectID: projectID, name: "return-grille", value: 0.03),
.init(projectID: projectID, name: "balancing-damper", value: 0.03),
]
}
}
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 }
}
}

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 {
case `return`
case supply
@@ -85,6 +105,38 @@ extension EffectiveLength {
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

View File

@@ -50,6 +50,22 @@ extension EquipmentInfo {
self.coolingCFM = coolingCFM
}
}
public struct Update: Codable, Equatable, Sendable {
public let staticPressure: Double?
public let heatingCFM: Int?
public let coolingCFM: Int?
public init(
staticPressure: Double? = nil,
heatingCFM: Int? = nil,
coolingCFM: Int? = nil
) {
self.staticPressure = staticPressure
self.heatingCFM = heatingCFM
self.coolingCFM = coolingCFM
}
}
}
#if DEBUG

View File

@@ -9,6 +9,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
public let city: String
public let state: String
public let zipCode: String
public let sensibleHeatRatio: Double?
public let createdAt: Date
public let updatedAt: Date
@@ -19,6 +20,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -28,6 +30,7 @@ public struct Project: Codable, Equatable, Identifiable, Sendable {
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
self.createdAt = createdAt
self.updatedAt = updatedAt
}
@@ -42,6 +45,7 @@ extension Project {
public let city: String
public let state: String
public let zipCode: String
public let sensibleHeatRatio: Double?
public init(
name: String,
@@ -49,12 +53,60 @@ extension Project {
city: String,
state: String,
zipCode: String,
sensibleHeatRatio: Double? = nil,
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
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 let name: String?
public let streetAddress: String?
public let city: String?
public let state: String?
public let zipCode: String?
public let sensibleHeatRatio: Double?
public init(
name: String? = nil,
streetAddress: String? = nil,
city: String? = nil,
state: String? = nil,
zipCode: String? = nil,
sensibleHeatRatio: Double? = nil
) {
self.name = name
self.streetAddress = streetAddress
self.city = city
self.state = state
self.zipCode = zipCode
self.sensibleHeatRatio = sensibleHeatRatio
}
}
}

View File

@@ -6,8 +6,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
public let projectID: Project.ID
public let name: String
public let heatingLoad: Double
public let coolingLoad: CoolingLoad
public let coolingTotal: Double
public let coolingSensible: Double?
public let registerCount: Int
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public let createdAt: Date
public let updatedAt: Date
@@ -16,8 +18,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
projectID: Project.ID,
name: String,
heatingLoad: Double,
coolingLoad: CoolingLoad,
coolingTotal: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1,
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -25,8 +29,10 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.projectID = projectID
self.name = name
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
self.registerCount = registerCount
self.rectangularSizes = rectangularSizes
self.createdAt = createdAt
self.updatedAt = updatedAt
}
@@ -34,13 +40,12 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
extension Room {
// TODO: Maybe remove project ID, and make dependencies that retrieves current project id??
public struct Create: Codable, Equatable, Sendable {
public let projectID: Project.ID
public let name: String
public let heatingLoad: Double
public let coolingTotal: Double
public let coolingSensible: Double
public let coolingSensible: Double?
public let registerCount: Int
public init(
@@ -48,7 +53,7 @@ extension Room {
name: String,
heatingLoad: Double,
coolingTotal: Double,
coolingSensible: Double,
coolingSensible: Double? = nil,
registerCount: Int = 1
) {
self.projectID = projectID
@@ -59,6 +64,59 @@ extension Room {
self.registerCount = registerCount
}
}
public struct Update: Codable, Equatable, Sendable {
public let name: String?
public let heatingLoad: Double?
public let coolingTotal: Double?
public let coolingSensible: Double?
public let registerCount: Int?
public let rectangularSizes: [DuctSizing.RectangularDuct]?
public init(
name: String? = nil,
heatingLoad: Double? = nil,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
registerCount: Int? = nil
) {
self.name = name
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
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
}
}
}
#if DEBUG
@@ -70,7 +128,7 @@ extension Room {
projectID: UUID(0),
name: "Kitchen",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1234,
registerCount: 2,
createdAt: Date(),
updatedAt: Date()
@@ -80,7 +138,7 @@ extension Room {
projectID: UUID(1),
name: "Bedroom - 1",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1456,
registerCount: 1,
createdAt: Date(),
updatedAt: Date()
@@ -90,7 +148,7 @@ extension Room {
projectID: UUID(2),
name: "Family Room",
heatingLoad: 12345,
coolingLoad: .init(total: 12345, sensible: 12345),
coolingTotal: 1673,
registerCount: 3,
createdAt: Date(),
updatedAt: Date()

View File

@@ -45,6 +45,7 @@ extension SiteRoute.Api {
public enum ProjectRoute: Sendable, Equatable {
case create(Project.Create)
case delete(id: Project.ID)
case detail(id: Project.ID, route: DetailRoute)
case get(id: Project.ID)
case index
@@ -74,6 +75,31 @@ extension SiteRoute.Api {
Path { rootPath }
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

@@ -1,4 +1,5 @@
import CasePathsCore
import FluentKit
import Foundation
@preconcurrency import URLRouting

File diff suppressed because it is too large Load Diff

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 Foundation
// FIX: Remove username.
public struct User: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let email: String
public let username: String
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
email: String,
username: String,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.username = username
self.email = email
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -27,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
extension User {
public struct Create: Codable, Equatable, Sendable {
public let username: String
public let email: String
public let password: String
public let confirmPassword: String
public init(
username: String,
email: String,
password: String,
confirmPassword: String
) {
self.username = username
self.email = email
self.password = password
self.confirmPassword = confirmPassword
@@ -48,10 +43,12 @@ extension User {
public struct Login: Codable, Equatable, Sendable {
public let email: String
public let password: String
public let next: String?
public init(email: String, password: String) {
public init(email: String, password: String, next: String? = nil) {
self.email = email
self.password = password
self.next = next
}
}

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

@@ -16,7 +16,7 @@ public struct SubmitButton: HTML, Sendable {
button(
.class(
"""
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
btn btn-secondary
"""
),
.type(type)
@@ -53,11 +53,11 @@ public struct CancelButton: HTML, Sendable {
}
public struct EditButton: HTML, Sendable {
let title: String
let title: String?
let type: HTMLAttribute<HTMLTag.button>.ButtonType
public init(
title: String = "Edit",
title: String? = nil,
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
) {
self.title = title
@@ -65,16 +65,11 @@ public struct EditButton: HTML, Sendable {
}
public var body: some HTML<HTMLTag.button> {
button(
.class(
"""
text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg
"""
),
.type(type)
) {
button(.class("btn"), .type(type)) {
div(.class("flex")) {
span(.class("pe-2")) { title }
if let title {
span(.class("pe-2")) { title }
}
SVG(.squarePen)
}
}
@@ -86,6 +81,22 @@ public struct PlusButton: HTML, Sendable {
public init() {}
public var body: some HTML<HTMLTag.button> {
button(.type(.button)) { SVG(.circlePlus) }
button(
.type(.button),
.class("btn")
) { SVG(.circlePlus) }
}
}
public struct TrashButton: HTML, Sendable {
public init() {}
public var body: some HTML<HTMLTag.button> {
button(
.type(.button),
.class("btn btn-error")
) {
SVG(.trash)
}
}
}

View File

@@ -0,0 +1,20 @@
import Elementary
import Foundation
public struct DateView: HTML, Sendable {
let date: Date
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
public init(_ date: Date) {
self.date = date
}
public var body: some HTML<HTMLTag.span> {
span { formatter.string(from: date) }
}
}

View File

@@ -1,4 +1,5 @@
import Elementary
import Foundation
import ManualDCore
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
@@ -7,3 +8,49 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
href(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.form {
public static func action(route: SiteRoute.View) -> Self {
action(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute where Tag == HTMLTag.input {
public static func value(_ string: String?) -> Self {
value(string ?? "")
}
public static func value(_ int: Int?) -> Self {
value(int == nil ? "" : "\(int!)")
}
public static func value(_ double: Double?) -> Self {
value(double == nil ? "" : "\(double!)")
}
public static func value(_ uuid: UUID?) -> Self {
value(uuid?.uuidString ?? "")
}
}
extension HTMLAttribute where Tag == HTMLTag.button {
public static func showModal(id: String) -> Self {
.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

@@ -23,8 +23,15 @@ extension HTMLAttribute.hx {
put(SiteRoute.View.router.path(for: route))
}
// @Sendable
// static func delete(route: SiteRoute.Api) -> HTMLAttribute {
// delete(SiteRoute.Api.router.path(for: route))
// }
@Sendable
public static func delete(route: SiteRoute.View) -> HTMLAttribute {
delete(SiteRoute.View.router.path(for: route))
}
}
extension HTMLAttribute.hx {
@Sendable
public static func indicator() -> HTMLAttribute {
indicator(".hx-indicator")
}
}

View File

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

View File

@@ -1,5 +1,26 @@
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 {
let id: String?

View File

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

@@ -2,36 +2,37 @@ import Elementary
public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
let closeButton: Bool
let dismiss: Bool
let id: String
let inner: T
public init(
id: String,
closeButton: Bool = true,
dismiss: Bool,
@HTMLBuilder inner: () -> T
) {
self.closeButton = closeButton
self.dismiss = dismiss
self.id = id
self.inner = inner()
}
public var body: some HTML {
if dismiss {
div(.id(id)) {}
} else {
div(
.id(id),
.class(
"""
fixed top-40 left-[25vw] w-1/2 z-50 text-gray-800
bg-gray-200 border border-gray-400
rounded-lg shadow-lg mx-10
"""
)
) {
public var body: some HTML<HTMLTag.dialog> {
dialog(.id(id), .class("modal")) {
div(.class("modal-box")) {
if closeButton {
button(
.class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
.on(.click, "\(id).close()")
) {
SVG(.close)
}
}
inner
}
}
.attributes(.class("modal-open"), when: dismiss == false)
}
}

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,23 +15,66 @@ public struct SVG: HTML, Sendable {
extension SVG {
public enum Key: Sendable {
case badgeCheck
case ban
case chevronDown
case chevronRight
case chevronsLeft
case circlePlus
case circleUser
case close
case doorClosed
case email
case fan
case key
case mapPin
case rulerDimensionLine
case sidebarToggle
case squareFunction
case squarePen
case trash
case triangleAlert
case user
case wind
var svg: String {
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:
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>
"""
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:
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>
"""
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:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -47,6 +90,10 @@ extension SVG {
</g>
</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:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -64,9 +111,34 @@ extension SVG {
</g>
</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:
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>
"""
case .trash:
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>
"""
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:
return """
@@ -83,6 +155,10 @@ extension SVG {
</g>
</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

@@ -0,0 +1,78 @@
import DatabaseClient
import Dependencies
import Fluent
import ManualDClient
import ManualDCore
import Vapor
// FIX: Remove these, not used currently.
extension DatabaseClient.Projects {
func fetchPage(
userID: User.ID,
page: Int = 1,
limit: Int = 25
) async throws -> Page<Project> {
try await fetch(userID, .init(page: page, per: limit))
}
func fetchPage(
userID: User.ID,
page: PageRequest
) async throws -> Page<Project> {
try await fetch(userID, page)
}
}
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 {
func createDefaults(projectID: Project.ID) async throws {
let defaults = ComponentPressureLoss.Create.default(projectID: projectID)
for loss in defaults {
_ = try await create(loss)
}
}
}
extension PageRequest {
static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

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

@@ -46,9 +46,6 @@ extension ViewController {
self.currentUser = currentUser
}
func authenticate(_ user: User) {
self.authenticateUser(user)
}
}
}
@@ -58,7 +55,35 @@ extension ViewController: DependencyKey {
// FIX: Fix.
public static let liveValue = Self(
view: { request in
try await request.render()
await request.render()
}
)
}
extension ViewController.Request {
func authenticate(
_ login: User.Login
) async throws -> User {
@Dependency(\.database.users) var users
let token = try await users.login(login)
let user = try await users.get(token.userID)!
authenticateUser(user)
logger.debug("Logged in user: \(user.id)")
return user
}
@discardableResult
func createAndAuthenticate(
_ signup: User.Create
) async throws -> User {
@Dependency(\.database.users) var users
let user = try await users.create(signup)
let _ = try await users.login(
.init(email: signup.email, password: signup.password)
)
authenticateUser(user)
logger.debug("Created and logged in user: \(user.id)")
return user
}
}

View File

@@ -3,208 +3,677 @@ import Dependencies
import Elementary
import Foundation
import ManualDCore
import Styleguide
extension ViewController.Request {
func render() async throws -> AnySendableHTML {
func render() async -> AnySendableHTML {
@Dependency(\.database) var database
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):
switch route {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm()
case .index(let next):
return await view {
LoginForm(next: next)
}
case .submit(let login):
let token = try await database.users.login(login)
let user = try await database.users.get(token.userID)!
authenticate(user)
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
ProjectsTable(userID: user.id, projects: projects)
// let _ = try await authenticate(login)
return await view {
await ResultView {
try await authenticate(login)
} onSuccess: { _ in
LoggedIn(next: login.next)
}
}
}
case .signup(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .project(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .room(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .frictionRate(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .effectiveLength(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
// case .user(let route):
// return try await route.renderView(isHtmxRequest: isHtmxRequest)
default:
// FIX: FIX
return try await _render(isHtmxRequest: false) {
div { "Fix me!" }
switch route {
case .index:
return await view {
LoginForm(style: .signup)
}
case .submit(let request):
// Create a new user and log them in.
return await view {
await ResultView {
try await createAndAuthenticate(request)
} 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):
return await route.renderView(on: self)
case .user(let route):
return await route.renderView(on: self)
}
}
func view<C: HTML>(
@HTMLBuilder inner: () async -> C
) async -> AnySendableHTML where C: Sendable {
let inner = await inner()
let theme = await self.theme
return MainPage(displayFooter: displayFooter, theme: theme) {
inner
}
}
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 {
case .login, .signup:
return false
default:
return true
}
}
}
extension SiteRoute.View.ProjectRoute {
private var shouldShowSidebar: Bool {
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .index, .page: return false
default: return true
}
}
case .index:
return await request.view {
await ResultView {
let user = try request.currentUser()
return try await (
user.id,
database.projects.fetchPage(userID: user.id)
)
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database.projects) var projects
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
}
}
case .page(let page):
return await ResultView {
let user = try request.currentUser()
return try await (
user.id,
database.projects.fetch(user.id, page)
)
} onSuccess: { (userID, projects) in
ProjectsTable(userID: userID, projects: projects)
}
return try await _render(
isHtmxRequest: isHtmxRequest,
showSidebar: shouldShowSidebar
) {
switch self {
case .create(let form):
return await request.view {
await ResultView {
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):
return await ResultView {
try await database.projects.delete(id)
}
case .update(let id, let form):
return await projectView(on: request, projectID: id) {
_ = try await database.projects.update(id, form)
}
case .detail(let projectID, let route):
switch route {
case .index:
// ProjectView(project: .mock)
let page = try await projects.fetch(UUID(0), .init(page: 1, per: 25))
ProjectsTable(userID: UUID(0), projects: page)
case .page(let page, let limit):
let page = try await projects.fetch(UUID(0), .init(page: page, per: limit))
ProjectsTable.Rows(projects: page)
case .form(let dismiss):
ProjectForm(dismiss: dismiss)
case .create:
div { "Fix me!" }
return await projectView(on: request, projectID: projectID)
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):
return await route.renderView(on: request, projectID: projectID)
case .equivalentLength(let route):
return await route.renderView(on: request, projectID: projectID)
case .frictionRate(let route):
return await route.renderView(on: request, projectID: projectID)
case .rooms(let route):
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.RoomRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .form(let dismiss):
return RoomForm(dismiss: dismiss)
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .rooms) {
RoomsView(rooms: Room.mocks)
}
}
}
}
extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
extension SiteRoute.View.FrictionRateRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) {
FrictionRateView()
return await equipmentView(on: request, projectID: projectID)
case .submit(let form):
return await equipmentView(on: request, projectID: projectID) {
_ = try await database.equipment.create(form)
}
case .form(let type, let dismiss):
// FIX: Forms need to reference existing items.
switch type {
case .equipmentInfo:
return EquipmentForm(dismiss: dismiss)
case .componentPressureLoss:
return ComponentLossForm(dismiss: dismiss)
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)
}
}
}
}
}
extension SiteRoute.View.FrictionRateRoute.FormType {
var id: String {
extension SiteRoute.View.ProjectRoute.RoomRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .equipmentInfo:
return "equipmentForm"
case .componentPressureLoss:
return "componentLossForm"
case .delete(let id):
return await ResultView {
try await database.rooms.delete(id)
}
case .index:
return await roomsView(on: request, projectID: projectID)
case .submit(let form):
return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.create(form)
}
case .update(let id, let form):
return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.update(id, form)
}
case .updateSensibleHeatRatio(let form):
return await roomsView(on: request, projectID: projectID) {
_ = try await database.projects.update(
form.projectID,
.init(sensibleHeatRatio: form.sensibleHeatRatio)
)
}
}
}
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.EffectiveLengthRoute {
extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
}
case .form(let dismiss):
return EffectiveLengthForm(dismiss: dismiss)
return await view(on: request, projectID: projectID)
}
}
case .field(let type):
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
switch self {
case .index:
return EmptyHTML()
case .delete(let id):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.delete(id)
}
case .submit(let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.create(form)
}
case .update(let id, let form):
return await view(on: request, projectID: projectID) {
_ = try await database.componentLoss.update(id, form)
}
}
}
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 {
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.ProjectRoute.EquivalentLengthRoute {
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .delete(let id):
return await ResultView {
try await database.effectiveLength.delete(id)
}
case .index:
return await self.view(on: request, projectID: projectID)
case .field(let type, let style):
switch type {
case .straightLength:
return StraightLengthField()
case .group:
return GroupField()
// FIX:
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)
}
}
}
}
}
extension SiteRoute.View.SignupRoute {
extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database.users) var users
func renderView(
on request: ViewController.Request,
projectID: Project.ID
) async -> AnySendableHTML {
@Dependency(\.database) var database
@Dependency(\.manualD) var manualD
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm(style: .signup)
}
case .submit(let request):
_ = try await users.create(request)
// FIX: We should just login the new user at this point.
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm()
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)
}
// default:
// return div { "Fix Me!" }
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)
}
}
}
}
}
// extension SiteRoute.View.LoginRoute {
// func renderView(on req: ViewController.Request) async throws -> AnySendableHTML {
//
// @Dependency(\.database) var database
//
// return try await _render(isHtmxRequest: req.isHtmxRequest, showSidebar: false) {
// switch self {
// case .index:
// LoginForm()
// case .submit(let login):
// // FIX:
// // div { "Logged in Success! Fix me!" }
// let token = try await database.users.login(login)
// let user = try await database.users.get(token.userID)!
// _ = req.authenticate(user)
// // req.authenticate(user)
// let page = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
// ProjectsTable(userID: user.id, projects: page)
// }
// }
// }
// }
extension SiteRoute.View.UserRoute {
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: Sidebar.ActiveTab = .projects,
showSidebar: Bool = true,
@HTMLBuilder inner: () async throws -> C
) async throws -> AnySendableHTML where C: Sendable {
let inner = try await inner()
if isHtmxRequest {
return inner
}
return MainPage(
active: activeTab,
showSidebar: showSidebar
) {
inner
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
switch self {
case .profile(let route):
return await route.renderView(on: request)
}
}
}
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)
}
}
}
}

View File

@@ -0,0 +1,72 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
// FIX: The value field is sometimes wonky as far as what values it accepts.
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 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 {
ModalForm(id: Self.id(componentLoss), dismiss: dismiss) {
h1(.class("text-2xl font-bold")) { "Component Loss" }
form(
.class("space-y-4 p-4"),
componentLoss == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
if let componentLoss {
input(.class("hidden"), .name("id"), .value("\(componentLoss.id)"))
}
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

@@ -0,0 +1,90 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss]
let projectID: Project.ID
private var total: Double {
componentPressureLosses.total
}
private var sortedLosses: [ComponentPressureLoss] {
componentPressureLosses.sorted {
$0.value > $1.value
}
}
var body: some HTML {
div(.class("space-y-4")) {
Row {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
PlusButton()
.attributes(
.class("btn-primary text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
.tooltip("Add component loss")
}
.attributes(.class("px-4"))
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl font-bold")) {
th { "Name" }
th { "Value" }
th(.class("min-w-[200px]")) {}
}
}
tbody {
for row in sortedLosses {
TableRow(row: row)
}
}
}
}
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,69 +3,223 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Add back buttons / capability??
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 type: EffectiveLength.EffectiveLengthType
let effectiveLength: EffectiveLength?
var id: String { Self.id(effectiveLength) }
init(
projectID: Project.ID,
dismiss: Bool,
type: EffectiveLength.EffectiveLengthType = .supply
) {
self.projectID = projectID
self.dismiss = dismiss
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 {
ModalForm(id: "effectiveLengthForm", dismiss: dismiss) {
ModalForm(
id: id,
dismiss: dismiss
) {
h1(.class("text-2xl font-bold")) { "Effective Length" }
form(.class("space-y-4 p-4")) {
div {
label(.for("name")) { "Name" }
Input(id: "name", placeholder: "Name")
.attributes(.type(.text), .required, .autofocus)
div(.id("formStep_\(id)"), .class("mt-4")) {
StepOne(projectID: projectID, effectiveLength: effectiveLength)
}
}
}
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" }
select(
.id("type"), .name("type"),
.class("w-full border rounded-md")
) {
option(.value("supply")) { "Supply" }
option(.value("return")) { "Return" }
}
LabeledInput(
"Name",
.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 {
Label { "Straigth Lengths" }
button(
.type(.button),
.hx.get(route: .effectiveLength(.field(.straightLength))),
.hx.get(
route: .project(.detail(projectID, .equivalentLength(.field(.straightLength))))
),
.hx.target("#straightLengths"),
.hx.swap(.beforeEnd)
) {
SVG(.circlePlus)
}
}
div(.id("straightLengths")) {
StraightLengthField()
div(.id("straightLengths"), .class("space-y-4")) {
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 {
Label { "Groups" }
button(
.type(.button),
.hx.get(route: .effectiveLength(.field(.group))),
.hx.get(
route: .project(
.detail(projectID, .equivalentLength(.field(.group, style: stepTwo.type))))
),
.hx.target("#groups"),
.hx.swap(.beforeEnd)
) {
SVG(.circlePlus)
}
}
div(.id("groups"), .class("space-y-4")) {
GroupField()
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 {
div {}
div(.class("space-x-4")) {
CancelButton()
.attributes(
.hx.get(route: .effectiveLength(.form(dismiss: true))),
.hx.target("#effectiveLengthForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
SubmitButton()
}
}
}
@@ -80,29 +234,127 @@ struct StraightLengthField: HTML, Sendable {
}
var body: some HTML<HTMLTag.div> {
div(.class("pb-4")) {
Input(
name: "straightLengths[]",
placeholder: "Length"
Row {
LabeledInput(
"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 {
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 {
Row {
Input(name: "group[][group]", placeholder: "Group")
.attributes(.type(.number), .min("0"))
Input(name: "group[][letter]", placeholder: "Letter")
.attributes(.type(.text))
Input(name: "group[][length]", placeholder: "Length")
.attributes(.type(.number), .min("0"))
Input(name: "group[][quantity]", placeholder: "Quantity")
.attributes(.type(.number), .min("1"), .value("1"))
div(.class("grid grid-cols-3 gap-2 p-2 border rounded-lg shadow-sm")) {
GroupSelect(style: style)
LabeledInput(
"Letter",
.name("group[letter]"),
.type(.text),
.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"), .hx.ext("remove"))
}
}
struct GroupSelect: HTML, Sendable {
let style: EffectiveLength.EffectiveLengthType
var body: some HTML {
label(.class("select")) {
span(.class("label")) { "Group" }
select(
.name("group[group]"),
.autofocus
) {
for value in style.selectOptions {
option(.value("\(value)")) { "\(value)" }
}
}
}
}
}
struct GroupTypeSelect: HTML, Sendable {
let projectID: Project.ID
let selected: EffectiveLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases {
option(
.value("\(value.rawValue)"),
) { value.title }
.attributes(.selected, when: value == selected)
}
}
}
}
}
extension EffectiveLength.EffectiveLengthType {
var title: String { rawValue.capitalized }
var selectOptions: [Int] {
switch self {
case .return:
return [5, 6, 7, 8, 10, 11, 12]
case .supply:
return [1, 2, 3, 4, 8, 9, 11, 12]
}
.attributes(.class("space-x-2"))
}
}

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 {
@Environment(ProjectViewValue.$projectID) var projectID
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 {
div(
.class("m-4")
) {
Row {
h1(.class("text-2xl font-bold")) { "Effective Lengths" }
div(.class("space-y-4")) {
PageTitleRow {
PageTitle { "Equivalent Lengths" }
PlusButton()
.attributes(
.hx.get(route: .effectiveLength(.form(dismiss: false))),
.hx.target("#effectiveLengthForm"),
.hx.swap(.outerHTML)
.class("btn-primary"),
.showModal(id: EffectiveLengthForm.id(nil))
)
// button(
// .hx.get(route: .effectiveLength(.form(dismiss: false))),
// .hx.target("#effectiveLengthForm"),
// .hx.swap(.outerHTML)
// ) {
// Icon(.circlePlus)
// }
.tooltip("Add equivalent length")
}
.attributes(.class("pb-6"))
div(
.id("effectiveLengths"),
.class("space-y-6")
) {
for row in effectiveLengths {
EffectiveLengthView(effectiveLength: row)
}
}
EffectiveLengthForm(projectID: projectID, dismiss: true)
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

@@ -0,0 +1,83 @@
import Elementary
import ManualDCore
import Styleguide
// TODO: Have form hold onto equipment info model to edit.
struct EquipmentInfoForm: HTML, Sendable {
static let id = "equipmentForm"
@Environment(ProjectViewValue.$projectID) var projectID
let dismiss: Bool
let equipmentInfo: EquipmentInfo?
var staticPressure: String {
guard let staticPressure = equipmentInfo?.staticPressure else {
return "0.5"
}
return "\(staticPressure)"
}
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .equipment(.index)))
)
.appendingPath(equipmentInfo?.id)
}
var body: some HTML {
ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" }
form(
.class("grid grid-cols-1 gap-4"),
equipmentInfo != nil
? .hx.patch(route)
: .hx.post(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
if let equipmentInfo {
input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)"))
}
LabeledInput(
"Static Pressure",
.name("staticPressure"),
.type(.number),
.value(staticPressure),
.min("0"),
.max("1.0"),
.step("0.1"),
.required
)
LabeledInput(
"Heating CFM",
.name("heatingCFM"),
.type(.number),
.value(equipmentInfo?.heatingCFM),
.placeholder("1000"),
.min("0"),
.required,
.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

@@ -0,0 +1,63 @@
import Elementary
import ManualDCore
import Styleguide
struct EquipmentInfoView: HTML, Sendable {
let equipmentInfo: EquipmentInfo?
var projectID: Project.ID
var body: some HTML {
div(
.class("space-y-4"),
.id("equipmentInfo")
) {
PageTitleRow {
PageTitle { "Equipment Details" }
EditButton()
.attributes(
.class("btn-primary"),
.showModal(id: EquipmentInfoForm.id)
)
.tooltip("Edit equipment details")
}
if let equipmentInfo {
table(.class("table table-zebra")) {
tbody(.class("text-lg")) {
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)
}
}
}
}
}
}
EquipmentInfoForm(
dismiss: equipmentInfo != nil,
equipmentInfo: equipmentInfo
)
}
}
}

View File

@@ -1,38 +0,0 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct ComponentLossForm: HTML, Sendable {
let dismiss: Bool
var body: some HTML {
ModalForm(id: "componentLossForm", dismiss: dismiss) {
h1(.class("text-2xl font-bold")) { "Component Loss" }
form(.class("space-y-4 p-4")) {
div {
label(.for("name")) { "Name" }
Input(id: "name", placeholder: "Name")
.attributes(.type(.text), .required, .autofocus)
}
div {
label(.for("value")) { "Value" }
Input(id: "name", placeholder: "Pressure loss")
.attributes(.type(.number), .min("0"), .max("1"), .step("0.1"), .required)
}
Row {
div {}
div {
CancelButton()
.attributes(
.hx.get(route: .frictionRate(.form(.componentPressureLoss, dismiss: true))),
.hx.target("#componentLossForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
}
}
}
}
}

View File

@@ -1,50 +0,0 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss]
private var total: Double {
componentPressureLosses.reduce(into: 0) { $0 += $1.value }
}
var body: some HTML {
div(
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4
"""
)
) {
Row {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
PlusButton()
.attributes(
.hx.get(route: .frictionRate(.form(.componentPressureLoss, dismiss: false))),
.hx.target("#componentLossForm"),
.hx.swap(.outerHTML)
)
}
for row in componentPressureLosses {
Row {
Label { row.name }
Number(row.value)
}
.attributes(.class("border-b border-gray-200"))
}
Row {
Label { "Total" }
Number(total)
.attributes(.class("text-xl font-bold"))
}
}
// div(.id("componentLossForm")) {}
ComponentLossForm(dismiss: true)
}
}

View File

@@ -1,46 +0,0 @@
import Elementary
import ManualDCore
import Styleguide
// TODO: Have form hold onto equipment info model to edit.
struct EquipmentForm: HTML, Sendable {
let dismiss: Bool
var body: some HTML {
ModalForm(id: "equipmentForm", dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" }
form(.class("space-y-4 p-4")) {
div {
label(.for("staticPressure")) { "Static Pressure" }
Input(id: "staticPressure", placeholder: "Static pressure")
.attributes(
.type(.number), .value("0.5"), .min("0"), .max("1.0"), .step("0.1")
)
}
div {
label(.for("heatingCFM")) { "Heating CFM" }
Input(id: "heatingCFM", placeholder: "CFM")
.attributes(.type(.number), .min("0"))
}
div {
label(.for("coolingCFM")) { "Cooling CFM" }
Input(id: "coolingCFM", placeholder: "CFM")
.attributes(.type(.number), .min("0"))
}
Row {
div {}
div(.class("space-x-4")) {
CancelButton()
.attributes(
.hx.get(route: .frictionRate(.form(.equipmentInfo, dismiss: true))),
.hx.target("#equipmentForm"),
.hx.swap(.outerHTML)
)
SubmitButton(title: "Save")
}
}
}
}
}
}

View File

@@ -1,45 +0,0 @@
import Elementary
import ManualDCore
import Styleguide
struct EquipmentInfoView: HTML, Sendable {
let equipmentInfo: EquipmentInfo
var body: some HTML {
div(.class("space-y-4 border border-gray-200 rounded-lg shadow-lg p-4")) {
Row {
h1(.class("text-2xl font-bold")) { "Equipment Info" }
}
Row {
Label { "Static Pressure" }
Number(equipmentInfo.staticPressure)
}
.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"))
Row {
div {}
EditButton()
.attributes(
.hx.get(route: .frictionRate(.form(.equipmentInfo))),
.hx.target("#equipmentForm"),
.hx.swap(.outerHTML)
)
}
}
div(.id("equipmentForm")) {}
}
}

View File

@@ -1,14 +1,151 @@
import Elementary
import ManualDClient
import ManualDCore
import Styleguide
struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
let componentLosses: [ComponentPressureLoss]
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 {
div(.class("p-4 space-y-6")) {
h1(.class("text-4xl font-bold pb-6")) { "Friction Rate" }
EquipmentInfoView(equipmentInfo: EquipmentInfo.mock)
ComponentPressureLossesView(componentPressureLosses: ComponentPressureLoss.mock)
div(.class("space-y-6")) {
PageTitleRow {
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(
componentPressureLosses: componentLosses, projectID: projectID
)
}
}
}

View File

@@ -1,48 +1,133 @@
import Elementary
import ElementaryHTMX
import Foundation
import ManualDCore
import Styleguide
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" }
let inner: Inner
let activeTab: Sidebar.ActiveTab
let showSidebar: Bool
let theme: Theme?
let displayFooter: Bool
init(
active activeTab: Sidebar.ActiveTab,
showSidebar: Bool = true,
displayFooter: Bool = true,
theme: Theme? = nil,
_ inner: () -> Inner
) {
self.activeTab = activeTab
self.showSidebar = showSidebar
self.displayFooter = displayFooter
self.theme = theme
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 {
meta(.charset(.utf8))
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("/js/main.js")) {}
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 {
// div(.class("bg-white dark:bg-gray-800 dark:text-white")) {
div {
div(.class("flex flex-row")) {
if showSidebar {
Sidebar(active: activeTab)
}
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
inner
div(.class("flex flex-col min-h-screen min-w-full justify-between")) {
main(.class("flex flex-col min-h-screen min-w-full grow mb-auto")) {
inner
}
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"
}
}
}
}
}
}
script(.src("https://unpkg.com/lucide@latest")) {}
script {
"lucide.createIcons();"
}
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
}
}
struct LoggedIn: HTML, Sendable {
let next: String
init(next: String? = nil) {
self.next = next ?? SiteRoute.View.router.path(for: .project(.index))
}
var body: some HTML {
div(
.hx.get(next),
.hx.pushURL(true),
.hx.target("body"),
.hx.trigger(.event(.revealed)),
.hx.indicator()
) {
Indicator()
}
}
}
public protocol SendableHTMLDocument: HTMLDocument, Sendable {}

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

@@ -0,0 +1,71 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct ProjectDetail: HTML, Sendable {
let project: Project
var body: some HTML {
div {
PageTitleRow {
PageTitle { "Project" }
EditButton()
.attributes(
.class("btn-primary"),
.on(.click, "projectForm.showModal()")
)
.tooltip("Edit project", position: .left)
}
table(.class("table table-zebra text-lg")) {
tbody {
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
}
}
}
}
}
ProjectForm(dismiss: true, project: project)
}
}
}

View File

@@ -5,6 +5,8 @@ import Styleguide
struct ProjectForm: HTML, Sendable {
static let id = "projectForm"
let project: Project?
let dismiss: Bool
@@ -16,48 +18,74 @@ struct ProjectForm: HTML, Sendable {
self.project = project
}
var route: String {
SiteRoute.View.router.path(for: .project(.index))
.appendingPath(project?.id)
}
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" }
form(.class("space-y-4 p-4")) {
div {
label(.for("name")) { "Name" }
Input(id: "name", placeholder: "Name")
.attributes(.type(.text), .required, .autofocus)
}
div {
label(.for("streetAddress")) { "Address" }
Input(id: "streetAddress", placeholder: "Street Address")
.attributes(.type(.text), .required)
}
div {
label(.for("city")) { "City" }
Input(id: "city", placeholder: "City")
.attributes(.type(.text), .required)
}
div {
label(.for("state")) { "State" }
Input(id: "state", placeholder: "State")
.attributes(.type(.text), .required)
}
div {
label(.for("zipCode")) { "Zip" }
Input(id: "zipCode", placeholder: "Zip code")
.attributes(.type(.text), .required)
form(
.class("grid grid-cols-1 gap-4"),
project == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
if let project {
input(.class("hidden"), .name("id"), .value("\(project.id)"))
}
Row {
div {}
div(.class("space-x-4")) {
CancelButton()
.attributes(
.hx.get(route: .project(.form(dismiss: true))),
.hx.target("#projectForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
}
LabeledInput(
"Name",
.name("name"),
.type(.text),
.value(project?.name),
.placeholder("Project Name"),
.required,
.autofocus
)
LabeledInput(
"Address",
.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

@@ -1,59 +1,253 @@
import DatabaseClient
import Dependencies
import Elementary
import ElementaryHTMX
import Logging
import ManualDClient
import ManualDCore
import Styleguide
struct ProjectView: HTML, Sendable {
let project: Project
enum ProjectViewValue {
@TaskLocal static var projectID = Project.ID(0)
}
struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
let projectID: Project.ID
let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
let inner: Inner
let completedSteps: Project.CompletedSteps
init(
projectID: Project.ID,
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab,
completedSteps: Project.CompletedSteps,
@HTMLBuilder content: () -> Inner
) {
self.projectID = projectID
self.activeTab = activeTab
self.inner = content()
self.completedSteps = completedSteps
}
var body: some HTML {
div(
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4 m-4
"""
div(.class("drawer lg:drawer-open h-full")) {
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
div(.class("drawer-content overflow-auto")) {
Navbar(sidebarToggle: true)
div(.class("p-4")) {
inner
.environment(ProjectViewValue.$projectID, projectID)
}
}
Sidebar(
active: activeTab,
projectID: projectID,
completedSteps: completedSteps
)
) {
Row {
h1(.class("text-2xl font-bold")) { "Project" }
EditButton()
.attributes(
.hx.get(route: .project(.form(dismiss: false))),
.hx.target("#projectForm"),
.hx.swap(.outerHTML)
}
}
}
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]
"""
)
}
) {
Row {
Label("Name")
span { project.name }
}
.attributes(.class("border-b border-gray-200"))
ul(.class("w-full grow")) {
Row {
Label("Address")
span { project.streetAddress }
}
.attributes(.class("border-b border-gray-200"))
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)
}
Row {
Label("City")
span { project.city }
}
.attributes(.class("border-b border-gray-200"))
li(.class("w-full")) {
row(
title: "Rooms",
icon: .doorClosed,
route: .project(.detail(projectID, .rooms(.index))),
isComplete: completedSteps.rooms
)
.attributes(.data("active", value: "true"), when: active == .rooms)
}
Row {
Label("State")
span { project.state }
}
.attributes(.class("border-b border-gray-200"))
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)
}
Row {
Label("Zip")
span { project.zipCode }
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)
}
}
}
}
}
div(.id("projectForm")) {}
// TODO: Use SiteRoute.View routes as href.
private func row(
title: String,
icon: SVG.Key,
href: String,
isComplete: Bool,
hideIsComplete: Bool = false
) -> some HTML<HTMLTag.button> {
button(
.class(
"""
w-full gap-1 py-2 border-b-1 border-gray-200
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
"""
),
.hx.get(href),
.hx.pushURL(true),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
div(
.class(
"""
w-full p-2 gap-1
is-drawer-open:flex is-drawer-open:space-x-4
is-drawer-close:grid-cols-1
"""
)
) {
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)
}
}
}
}
private func row(
title: String,
icon: SVG.Key,
route: SiteRoute.View,
isComplete: Bool,
hideIsComplete: Bool = false
) -> some HTML<HTMLTag.button> {
row(
title: title, icon: icon, href: SiteRoute.View.router.path(for: route),
isComplete: isComplete, hideIsComplete: hideIsComplete
)
}
}
}
extension ManualDClient {
func frictionRate(
equipmentInfo: EquipmentInfo?,
componentLosses: [ComponentPressureLoss],
effectiveLength: EffectiveLength.MaxContainer
) async throws -> FrictionRateResponse? {
guard let staticPressure = equipmentInfo?.staticPressure else {
return nil
}
guard let totalEquivalentLength = effectiveLength.total else {
return nil
}
return try await self.frictionRate(
.init(
externalStaticPressure: staticPressure,
componentPressureLosses: componentLosses,
totalEffectiveLength: Int(totalEquivalentLength)
)
)
}
}

View File

@@ -17,28 +17,27 @@ struct ProjectsTable: HTML, Sendable {
var body: some HTML {
div {
Row {
h1(.class("text-2xl font-bold")) { "Projects" }
div(
.class("tooltip tooltip-left"),
.data("tip", value: "Add project")
) {
button(
.class("btn btn-primary w-[40px] text-2xl")
) {
"+"
Navbar(sidebarToggle: false)
div(.class("m-6")) {
PageTitleRow {
PageTitle { "Projects" }
Tooltip("Add project") {
PlusButton()
.attributes(
.class("btn-primary"),
.showModal(id: ProjectForm.id)
)
}
}
}
.attributes(.class("pb-6"))
.attributes(.class("pb-6"))
div(.class("overflow-x-auto rounded-box border")) {
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
@@ -46,6 +45,8 @@ struct ProjectsTable: HTML, Sendable {
}
}
}
ProjectForm(dismiss: true)
}
}
}
@@ -54,19 +55,52 @@ extension ProjectsTable {
struct Rows: HTML, Sendable {
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 {
for project in projects.items {
for (n, project) in projects.items.enumerated() {
tr(.id("\(project.id)")) {
td { "\(project.createdAt)" }
td { DateView(project.createdAt) }
td { "\(project.name)" }
td { "\(project.streetAddress)" }
td {
div(.class("flex justify-end space-x-6")) {
div(.class("join")) {
Tooltip("Delete project", position: tooltipPosition(n)) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(route: .project(.delete(id: project.id))),
.hx.confirm("Are you sure?"),
.hx.target("closest tr")
)
}
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)
}
}
}
}
}
}
}
// Have a row that when revealed fetches the next page,
// if there are more pages left.
if projects.metadata.pageCount > projects.metadata.page {
tr(
.hx.get(route: .project(.page(page: projects.metadata.page + 1, limit: 25))),
.hx.get(route: .project(.page(.next(projects)))),
.hx.trigger(.event(.revealed)),
.hx.swap(.outerHTML),
.hx.target("this"),

View File

@@ -8,46 +8,101 @@ import Styleguide
// TODO: Need to hold the project ID in hidden input field.
struct RoomForm: HTML, Sendable {
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 projectID: Project.ID
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 {
ModalForm(id: "roomForm", dismiss: dismiss) {
ModalForm(id: Self.id(room), dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6")) { "Room" }
form {
div {
label(.for("name")) { "Name:" }
Input(id: "name", placeholder: "Room Name")
.attributes(.type(.text), .required, .autofocus)
form(
.class("grid grid-cols-1 gap-4"),
room == nil
? .hx.post(route)
: .hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
if let id = room?.id {
input(.class("hidden"), .name("id"), .value("\(id)"))
}
div {
label(.for("heatingLoad")) { "Heating Load:" }
Input(id: "heatingLoad", placeholder: "Heating Load")
.attributes(.type(.number), .required, .min("0"))
}
div {
label(.for("coolingLoad")) { "Cooling Load:" }
Input(id: "coolingLoad", placeholder: "Cooling Load")
.attributes(.type(.number), .required, .min("0"))
}
div {
label(.for("registerCount")) { "Registers:" }
Input(id: "registerCount", placeholder: "Register Count")
.attributes(.type(.number), .required, .value("1"), .min("1"))
}
Row {
// Force button to the right, probably a better way.
div {}
div(.class("space-x-4")) {
CancelButton()
.attributes(
.hx.get(route: .room(.form(dismiss: true))),
.hx.target("#roomForm"),
.hx.swap(.outerHTML)
)
SubmitButton()
}
}
.attributes(.class("py-4"))
LabeledInput(
"Name",
.name("name"),
.type(.text),
.placeholder("Name"),
.required,
.autofocus,
.value(room?.name)
)
LabeledInput(
"Heating Load",
.name("heatingLoad"),
.type(.number),
.placeholder("1234"),
.required,
.min("0"),
.value(room?.heatingLoad)
)
LabeledInput(
"Cooling Total",
.name("coolingTotal"),
.type(.number),
.placeholder("1234"),
.required,
.min("0"),
.value(room?.coolingTotal)
)
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

@@ -6,85 +6,236 @@ import ManualDCore
import Styleguide
struct RoomsView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [Room]
let sensibleHeatRatio: Double?
var body: some HTML {
div {
Row {
h1(.class("text-2xl font-bold")) { "Room Loads" }
div(
.class("tooltip tooltip-left"),
.data("tip", value: "Add room")
) {
button(
.hx.get(route: .room(.form(dismiss: false))),
.hx.target("#roomForm"),
.hx.swap(.outerHTML),
.class("btn btn-primary w-[40px] text-2xl")
) {
"+"
}
}
}
.attributes(.class("pb-6"))
div(.class("flex w-full flex-col")) {
PageTitleRow {
div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
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("Cooling Sensible") }
th { Label("Register Count") }
div(.class("col-span-2")) {
PageTitle { "Room Loads" }
}
div(.class("flex justify-end grow")) {
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)
}
}
tbody {
for room in rooms {
tr {
td { room.name }
td {
Number(room.heatingLoad)
.attributes(.class("text-error"))
}
td {
Number(room.coolingLoad.total)
.attributes(.class("text-success"))
}
td {
Number(room.coolingLoad.sensible)
.attributes(.class("text-info"))
}
td {
Number(room.registerCount)
}
}
}
// 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 {
Number(rooms.coolingSensibleTotal)
.attributes(.class("badge badge-outline badge-info badge-xl"))
}
td {}
}
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"))
}
}
}
RoomForm(dismiss: true)
SHRForm(
sensibleHeatRatio: sensibleHeatRatio,
dismiss: sensibleHeatRatio != nil
)
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"))
}
}
}
}
}
tbody {
for room in rooms {
RoomRow(room: room, shr: sensibleHeatRatio)
}
}
}
RoomForm(dismiss: true, projectID: projectID, room: nil)
}
}
public struct RoomRow: HTML, Sendable {
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 {
tr(.id("roomRow_\(room.id.idString)")) {
td { room.name }
td {
div(.class("flex justify-center")) {
Number(room.heatingLoad, digits: 0)
// .attributes(.class("text-error"))
}
}
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
)
}
}
}
}
struct SHRForm: HTML, Sendable {
static let id = "shrForm"
@Environment(ProjectViewValue.$projectID) var projectID
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 {
ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-xl font-bold mb-6")) {
"Sensible Heat Ratio"
}
form(
.class("grid grid-cols-1 gap-4"),
.hx.patch(route),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
LabeledInput(
"SHR",
.name("sensibleHeatRatio"),
.type(.number),
.value(sensibleHeatRatio),
.placeholder("0.83"),
.min("0"),
.max("1"),
.step("0.01"),
.autofocus
)
SubmitButton()
.attributes(.class("btn-block my-6"))
}
}
}
}
}
extension Array where Element == Room {
@@ -93,10 +244,15 @@ extension Array where Element == Room {
}
var coolingTotal: Double {
reduce(into: 0) { $0 += $1.coolingLoad.total }
reduce(into: 0) { $0 += $1.coolingTotal }
}
var coolingSensibleTotal: Double {
reduce(into: 0) { $0 += $1.coolingLoad.sensible }
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

@@ -1,86 +0,0 @@
import Elementary
import ManualDCore
import Styleguide
// TODO: Update to use DaisyUI drawer.
struct Sidebar: HTML {
let active: ActiveTab
var body: some HTML {
aside(
.class(
"""
h-screen sticky top-0 max-w-[280px] flex-none
border-r-2 border-gray-200
shadow-lg
"""
)
) {
// TODO: Move somewhere outside of the sidebar.
Row {
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
.attributes(.class("p-4"))
row(title: "Project", icon: .mapPin, route: .project(.index))
.attributes(.data("active", value: active == .projects ? "true" : "false"))
row(title: "Rooms", icon: .doorClosed, route: .room(.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: .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(
title: String,
icon: Icon.Key,
href: String
) -> some HTML<HTMLTag.a> {
a(
.class(
"""
flex w-full items-center gap-4
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,
icon: Icon.Key,
route: SiteRoute.View
) -> some HTML<HTMLTag.a> {
row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route))
}
}
extension Sidebar {
enum ActiveTab: Equatable, Sendable {
case projects
case rooms
case effectiveLength
case frictionRate
case ductSizing
}
}

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

@@ -5,47 +5,38 @@ import Styleguide
struct LoginForm: HTML, Sendable {
let style: Style
let next: String?
init(style: Style = .login) {
init(style: Style = .login, next: String? = nil) {
self.style = style
self.next = next
}
var body: some HTML {
div(
.id("loginForm"),
.class("flex items-center justify-center")
) {
ModalForm(id: "loginForm", closeButton: false, dismiss: false) {
h1(.class("text-2xl font-bold mb-6")) { style.title }
form(
.method(.post)
.method(.post),
.class("space-y-4")
) {
fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) {
legend(.class("fieldset-legend")) { style.title }
if style == .signup {
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"
}
}
if let next {
input(.class("hidden"), .name("next"), .value(next))
}
div {
label(.class("input validator w-full")) {
SVG(.email)
input(
.type(.email), .placeholder("Email"), .required,
.name("email"), .id("email"),
.name("email"), .id("email"), .autofocus
)
}
div(.class("validator-hint hidden")) { "Enter valid email address." }
}
div {
label(.class("input validator w-full")) {
SVG(.key)
input(
@@ -54,8 +45,10 @@ struct LoginForm: HTML, Sendable {
.name("password"), .id("password"),
)
}
}
if style == .signup {
if style == .signup {
div {
label(.class("input validator w-full")) {
SVG(.key)
input(
@@ -77,11 +70,16 @@ struct LoginForm: HTML, Sendable {
"At least one uppercase letter"
}
}
}
button(.class("btn btn-secondary mt-4")) { style.title }
div(.class("flex")) {
button(.class("btn btn-secondary mt-4 w-full")) { style.title }
}
div(.class("flex justify-center")) {
a(
.class("btn btn-link mt-4"),
.href(route: style == .signup ? .login(.index) : .signup(.index))
.class("btn btn-link"),
.href(route: style == .signup ? .login(.index(next: next)) : .signup(.index))
) {
style == .login ? "Sign Up" : "Login"
}

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)
#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,6 +0,0 @@
@import "tailwindcss";
@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";

Some files were not shown because too many files have changed in this diff Show More