16 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
46 changed files with 1953 additions and 780 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

@@ -20,8 +20,6 @@
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-base: 1rem;
--text-base--line-height: calc(1.5 / 1);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
@@ -30,9 +28,8 @@
--text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--font-weight-bold: 700;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--ease-out: cubic-bezier(0, 0, 0.2, 1);
@@ -4227,6 +4224,9 @@
.top-2 {
top: calc(var(--spacing) * 2);
}
.top-\[100vh\] {
top: 100vh;
}
.right-2 {
right: calc(var(--spacing) * 2);
}
@@ -4287,6 +4287,12 @@
}
}
}
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.left-0 {
left: calc(var(--spacing) * 0);
}
.join {
display: inline-flex;
align-items: stretch;
@@ -4801,9 +4807,18 @@
}
}
}
.col-span-1 {
grid-column: span 1 / span 1;
}
.col-span-2 {
grid-column: span 2 / span 2;
}
.col-span-3 {
grid-column: span 3 / span 3;
}
.col-span-5 {
grid-column: span 5 / span 5;
}
.timeline-end {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -5276,6 +5291,9 @@
.mx-2 {
margin-inline: calc(var(--spacing) * 2);
}
.mx-4 {
margin-inline: calc(var(--spacing) * 4);
}
.mx-auto {
margin-inline: auto;
}
@@ -5365,12 +5383,15 @@
}
}
}
.-my-2 {
margin-block: calc(var(--spacing) * -2);
.my-1 {
margin-block: calc(var(--spacing) * 1);
}
.my-1\.5 {
margin-block: calc(var(--spacing) * 1.5);
}
.my-4 {
margin-block: calc(var(--spacing) * 4);
}
.my-6 {
margin-block: calc(var(--spacing) * 6);
}
@@ -5586,8 +5607,11 @@
.me-4 {
margin-inline-end: calc(var(--spacing) * 4);
}
.me-8 {
margin-inline-end: calc(var(--spacing) * 8);
.me-6 {
margin-inline-end: calc(var(--spacing) * 6);
}
.me-\[140px\] {
margin-inline-end: 140px;
}
.modal-action {
@layer daisyui.l1.l2.l3 {
@@ -5628,6 +5652,9 @@
}
}
}
.-mt-2 {
margin-top: calc(var(--spacing) * -2);
}
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
@@ -5713,12 +5740,18 @@
font-weight: 600;
}
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-auto {
margin-bottom: auto;
}
.carousel-item {
@layer daisyui.l1.l2.l3 {
box-sizing: content-box;
@@ -6446,6 +6479,9 @@
.min-h-full {
min-height: 100%;
}
.min-h-screen {
min-height: 100vh;
}
.btn-wide {
@layer daisyui.l1.l2 {
width: 100%;
@@ -6574,14 +6610,35 @@
width: calc(var(--size-selector, 0.25rem) * 4);
}
}
.w-\[300px\] {
width: 300px;
}
.w-\[310px\] {
width: 310px;
}
.w-\[330px\] {
width: 330px;
}
.w-fit {
width: fit-content;
}
.w-full {
width: 100%;
}
.max-w-\[300px\] {
max-width: 300px;
.min-w-\[100px\] {
min-width: 100px;
}
.min-w-\[200px\] {
min-width: 200px;
}
.min-w-\[220px\] {
min-width: 220px;
}
.min-w-\[300px\] {
min-width: 300px;
}
.min-w-full {
min-width: 100%;
}
.flex-1 {
flex: 1;
@@ -6788,12 +6845,12 @@
.flex-col {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.flex-wrap {
flex-wrap: wrap;
}
.items-baseline {
align-items: baseline;
}
.items-center {
align-items: center;
}
@@ -6815,6 +6872,9 @@
.justify-start {
justify-content: flex-start;
}
.justify-items-end {
justify-items: end;
}
.gap-1 {
gap: calc(var(--spacing) * 1);
}
@@ -6824,6 +6884,23 @@
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.space-y-1 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-2 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-4 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
@@ -6859,8 +6936,14 @@
margin-inline-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-x-reverse)));
}
}
.overflow-x-auto {
overflow-x: auto;
.gap-y-2 {
row-gap: calc(var(--spacing) * 2);
}
.gap-y-4 {
row-gap: calc(var(--spacing) * 4);
}
.overflow-auto {
overflow: auto;
}
.timeline-box {
@layer daisyui.l1.l2.l3 {
@@ -6968,6 +7051,9 @@
.rounded-selector {
border-radius: var(--radius-selector);
}
.rounded-sm {
border-radius: var(--radius-sm);
}
.rounded-t-box {
border-top-left-radius: var(--radius-box);
border-top-right-radius: var(--radius-box);
@@ -7140,6 +7226,10 @@
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-b {
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
@@ -7257,9 +7347,6 @@
border-color: currentColor;
}
}
.border-base-300 {
border-color: var(--color-base-300);
}
.border-error {
border-color: var(--color-error);
}
@@ -7269,9 +7356,6 @@
.border-primary {
border-color: var(--color-primary);
}
.border-secondary {
border-color: var(--color-secondary);
}
.menu-active {
:where(:not(ul, details, .menu-title, .btn))& {
@layer daisyui.l1.l2 {
@@ -7429,9 +7513,15 @@
.bg-base-300 {
background-color: var(--color-base-300);
}
.bg-error {
background-color: var(--color-error);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-secondary {
background-color: var(--color-secondary);
}
.bg-white {
background-color: var(--color-white);
}
@@ -7723,6 +7813,9 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
.p-6 {
padding: calc(var(--spacing) * 6);
}
.menu-title {
@layer daisyui.l1.l2.l3 {
padding-inline: calc(0.25rem * 3);
@@ -7846,6 +7939,9 @@
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
@@ -7869,15 +7965,12 @@
.pe-2 {
padding-inline-end: calc(var(--spacing) * 2);
}
.pt-4 {
padding-top: calc(var(--spacing) * 4);
.pt-2 {
padding-top: calc(var(--spacing) * 2);
}
.pt-6 {
padding-top: calc(var(--spacing) * 6);
}
.pb-4 {
padding-bottom: calc(var(--spacing) * 4);
}
.pb-6 {
padding-bottom: calc(var(--spacing) * 6);
}
@@ -8495,12 +8588,6 @@
color: var(--color-warning);
}
}
.text-base-100 {
color: var(--color-base-100);
}
.text-base-300 {
color: var(--color-base-300);
}
.text-base-content {
color: var(--color-base-content);
}
@@ -8516,21 +8603,9 @@
.text-info {
color: var(--color-info);
}
.text-neutral {
color: var(--color-neutral);
}
.text-neutral-content {
color: var(--color-neutral-content);
}
.text-primary {
color: var(--color-primary);
}
.text-secondary {
color: var(--color-secondary);
}
.text-secondary-content {
color: var(--color-secondary-content);
}
.text-slate-900 {
color: var(--color-slate-900);
}
@@ -9417,13 +9492,6 @@
border-color: var(--color-red-500);
}
}
.hover\:rounded-lg {
&:hover {
@media (hover: hover) {
border-radius: var(--radius-lg);
}
}
}
.hover\:bg-neutral {
&:hover {
@media (hover: hover) {
@@ -9445,16 +9513,6 @@
}
}
}
.hover\:btn-success {
&:hover {
@media (hover: hover) {
@layer daisyui.l1.l2.l3 {
--btn-color: var(--color-success);
--btn-fg: var(--color-success-content);
}
}
}
}
.focus\:outline {
&:focus {
outline-style: var(--tw-outline-style);
@@ -9481,11 +9539,26 @@
color: var(--color-white);
}
}
.sm\:footer-horizontal {
@media (width >= 40rem) {
@layer daisyui.l1.l2 {
grid-auto-flow: column;
&.footer-center {
grid-auto-flow: row dense;
}
}
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.md\:grid-cols-3 {
@media (width >= 48rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:drawer-open {
@media (width >= 64rem) {
@layer daisyui.l1.l2.l3 {
@@ -9539,14 +9612,9 @@
}
}
}
.lg\:grid-cols-3 {
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.\32 xl\:table-cell {
@media (width >= 96rem) {
display: table-cell;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.is-drawer-close\:mx-auto {

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

Binary file not shown.

Binary file not shown.

View File

@@ -14,6 +14,8 @@ extension DatabaseClient {
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
}
}
@@ -67,6 +69,19 @@ extension DatabaseClient.Rooms: TestDependencyKey {
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()
}
)
}

View File

@@ -11,6 +11,9 @@ extension DatabaseClient {
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
}
}
@@ -34,10 +37,11 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
let model = try TrunkRoomModel(
trunkID: trunk.requireID(),
roomID: room.requireID(),
registers: registers
registers: registers,
type: request.type
)
try await model.save(on: database)
try roomProxies.append(model.toDTO())
try await roomProxies.append(model.toDTO(on: database))
}
return try .init(
@@ -55,15 +59,32 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
},
fetch: { projectID in
try await TrunkModel.query(on: database)
.with(\.$rooms)
.with(\.$project)
.with(\.$rooms)
.filter(\.$project.$id == projectID)
.all()
.map { try $0.toDTO() }
.toDTO(on: database)
},
get: { id in
try await TrunkModel.find(id, on: database)
.map { try $0.toDTO() }
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)
}
)
}
@@ -86,10 +107,25 @@ extension DuctSizing.TrunkSize.Create {
.init(
projectID: projectID,
type: type,
height: height
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 {
@@ -101,6 +137,7 @@ extension DuctSizing.TrunkSize {
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)
@@ -110,12 +147,14 @@ extension DuctSizing.TrunkSize {
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()
}
@@ -143,22 +182,30 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
@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]
registers: [Int],
type: DuctSizing.TrunkSize.TrunkType
) {
self.id = id
$trunk.id = trunkID
$room.id = roomID
self.registers = registers
self.type = type.rawValue
}
func toDTO() throws -> DuctSizing.TrunkSize.RoomProxy {
.init(
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
)
@@ -176,12 +223,15 @@ final class TrunkModel: Model, @unchecked Sendable {
@Parent(key: "projectID")
var project: ProjectModel
@Field(key: "height")
@OptionalField(key: "height")
var height: Int?
@Field(key: "type")
var type: String
@OptionalField(key: "name")
var name: String?
@Children(for: \.$trunk)
var rooms: [TrunkRoomModel]
@@ -192,21 +242,115 @@ final class TrunkModel: Model, @unchecked Sendable {
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() throws -> DuctSizing.TrunkSize {
try .init(
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.map { try $0.toDTO() },
height: height
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

@@ -14,6 +14,38 @@ extension Room {
}
}
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 } }
}
@@ -32,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.totalComponentPressureLoss
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

@@ -12,6 +12,29 @@ public struct ManualDClient: Sendable {
@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,
@@ -21,7 +44,6 @@ public struct ManualDClient: Sendable {
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
var registerIDCount = 1
var retval: [DuctSizing.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
@@ -53,9 +75,9 @@ public struct ManualDClient: Sendable {
retval.append(
.init(
registerID: "SR-\(registerIDCount)",
roomID: room.id,
roomName: "\(room.name)-\(n)",
roomRegister: n,
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
heatingCFM: heatingCFM,
@@ -69,13 +91,65 @@ public struct ManualDClient: Sendable {
rectangularWidth: rectangularWidth
)
)
registerIDCount += 1
}
}
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 {

View File

@@ -69,7 +69,7 @@ extension ComponentPressureLoss {
}
extension Array where Element == ComponentPressureLoss {
public var totalComponentPressureLoss: Double {
public var total: Double {
reduce(into: 0) { $0 += $1.value }
}
}

View File

@@ -21,11 +21,42 @@ public enum DuctSizing {
}
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 registerID: String
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
@@ -39,9 +70,9 @@ public enum DuctSizing {
public let rectangularWidth: Int?
public init(
registerID: String,
roomID: Room.ID,
roomName: String,
roomRegister: Int,
heatingLoad: Double,
coolingLoad: Double,
heatingCFM: Double,
@@ -54,9 +85,9 @@ public enum DuctSizing {
rectangularSize: RectangularDuct? = nil,
rectangularWidth: Int? = nil
) {
self.registerID = registerID
self.roomID = roomID
self.roomName = roomName
self.roomRegister = roomRegister
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.heatingCFM = heatingCFM
@@ -94,6 +125,35 @@ public enum DuctSizing {
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
@@ -101,19 +161,22 @@ extension DuctSizing {
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
height: Int? = nil,
name: String? = nil
) {
self.id = id
self.projectID = projectID
self.type = type
self.rooms = rooms
self.height = height
self.name = name
}
}
@@ -126,17 +189,40 @@ extension DuctSizing.TrunkSize {
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
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
}
}
@@ -145,16 +231,18 @@ extension DuctSizing.TrunkSize {
public var id: Room.ID { room.id }
public let room: Room
public let registers: [Int]?
public let registers: [Int]
public init(room: Room, registers: [Int]? = nil) {
public init(room: Room, registers: [Int]) {
self.room = room
self.registers = registers
}
}
public enum TrunkType: String, Codable, Equatable, Sendable {
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
case `return`
case supply
public static let allCases = [Self.supply, .return]
}
}

View File

@@ -607,9 +607,11 @@ extension SiteRoute.View.ProjectRoute {
public enum DuctSizingRoute: Equatable, Sendable {
case index
case deleteRectangularSize(Room.ID, DuctSizing.RectangularDuct.ID)
case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
case roomRectangularForm(Room.ID, RoomRectangularForm)
case trunk(TrunkRoute)
public static let roomPath = "room"
static let rootPath = "duct-sizing"
static let router = OneOf {
@@ -620,18 +622,20 @@ extension SiteRoute.View.ProjectRoute {
Route(.case(Self.deleteRectangularSize)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.delete
Query {
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
Field("register") { Int.parser() }
}
.map(.memberwise(DeleteRectangularDuct.init))
}
Route(.case(Self.roomRectangularForm)) {
Path {
rootPath
"room"
roomPath
Room.ID.parser()
}
Method.post
@@ -646,6 +650,85 @@ extension SiteRoute.View.ProjectRoute {
.map(.memberwise(RoomRectangularForm.init))
}
}
Route(.case(Self.trunk)) {
Path { rootPath }
TrunkRoute.router
}
}
public struct DeleteRectangularDuct: Equatable, Sendable {
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
public let register: Int
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
self.rectangularSizeID = rectangularSizeID
self.register = register
}
}
public enum TrunkRoute: Equatable, Sendable {
case delete(DuctSizing.TrunkSize.ID)
case submit(TrunkSizeForm)
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
public static let rootPath = "trunk"
static let router = OneOf {
Route(.case(Self.delete)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.delete
}
Route(.case(Self.submit)) {
Path {
rootPath
}
Method.post
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
Route(.case(Self.update)) {
Path {
rootPath
DuctSizing.TrunkSize.ID.parser()
}
Method.patch
Body {
FormData {
Field("projectID") { Project.ID.parser() }
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
Optionally {
Field("height") { Int.parser() }
}
Optionally {
Field("name", .string)
}
Many {
Field("rooms", .string)
}
}
.map(.memberwise(TrunkSizeForm.init))
}
}
}
}
public struct RoomRectangularForm: Equatable, Sendable {
@@ -653,6 +736,14 @@ extension SiteRoute.View.ProjectRoute {
public let register: Int
public let height: Int
}
public struct TrunkSizeForm: Equatable, Sendable {
public let projectID: Project.ID
public let type: DuctSizing.TrunkSize.TrunkType
public let height: Int?
public let name: String?
public let rooms: [String]
}
}
}

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

@@ -11,7 +11,7 @@ public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
}
public var body: some HTML<HTMLTag.div> {
div(.class("badge badge-lg badge-outline font-bold")) {
div(.class("badge badge-lg badge-outline")) {
inner
}
}

View File

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

View File

@@ -43,6 +43,14 @@ extension HTMLAttribute where Tag == HTMLTag.button {
extension HTML where Tag: HTMLTrait.Attributes.Global {
public func badge() -> _AttributedElement<Self> {
attributes(.class("badge badge-lg badge-outline font-bold"))
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

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

View File

@@ -1,5 +1,27 @@
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

View File

@@ -33,6 +33,7 @@ extension SVG {
case squareFunction
case squarePen
case trash
case triangleAlert
case user
case wind
@@ -135,6 +136,10 @@ extension SVG {
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 """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">

View File

@@ -1,6 +1,18 @@
import Elementary
public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
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
@@ -26,6 +38,8 @@ public struct Tooltip<Inner: HTML & Sendable>: HTML, Sendable {
}
}
extension Tooltip: Sendable where Inner: Sendable {}
public enum TooltipPosition: String, CaseIterable, Sendable {
public static let `default` = Self.left

View File

@@ -26,11 +26,14 @@ extension DatabaseClient.Projects {
extension DatabaseClient {
func calculateDuctSizes(projectID: Project.ID) async throws -> [DuctSizing.RoomContainer] {
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)
)
@@ -50,7 +53,7 @@ extension DatabaseClient {
guard componentLosses.count > 0 else { return nil }
let availableStaticPressure =
equipmentInfo.staticPressure - componentLosses.totalComponentPressureLoss
equipmentInfo.staticPressure - componentLosses.total
let designFrictionRate = (availableStaticPressure * 100) / tel

View File

@@ -6,20 +6,22 @@ extension ManualDClient {
func calculate(
rooms: [Room],
trunks: [DuctSizing.TrunkSize],
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
projectSHR: Double?,
logger: Logger? = nil
) async throws -> [DuctSizing.RoomContainer] {
guard let designFrictionRateResult else { return [] }
) 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 [] }
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,

View File

@@ -17,4 +17,9 @@ extension String {
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

@@ -2,6 +2,6 @@ import Foundation
extension UUID {
var idString: String {
uuidString.replacing("-", with: "")
uuidString.idString
}
}

View File

@@ -13,8 +13,16 @@ extension ViewController.Request {
switch route {
case .test:
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
return await view {
TestPage()
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 {
@@ -78,7 +86,7 @@ extension ViewController.Request {
let inner = await inner()
let theme = await self.theme
return MainPage(theme: theme) {
return MainPage(displayFooter: displayFooter, theme: theme) {
inner
}
}
@@ -90,6 +98,15 @@ extension ViewController.Request {
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 {
@@ -123,21 +140,23 @@ extension SiteRoute.View.ProjectRoute {
}
case .create(let form):
return 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)
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)
}
}
}
@@ -538,31 +557,48 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
case .index:
return await view(on: request, projectID: projectID)
case .deleteRectangularSize(let roomID, let rectangularSizeID):
case .deleteRectangularSize(let roomID, let request):
return await ResultView {
let room = try await database.rooms.deleteRectangularSize(roomID, rectangularSizeID)
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} onSuccess: { room in
DuctSizingView.RoomRow(room: room)
}
case .roomRectangularForm(let roomID, let form):
return await ResultView {
let room = try await database.rooms.update(
let room = try await database.rooms.updateRectangularSize(
roomID,
.init(
rectangularSizes: [
.init(id: form.id ?? .init(), register: form.register, height: form.height)
]
)
.init(id: form.id ?? .init(), register: form.register, height: form.height)
)
return try await database.calculateDuctSizes(projectID: projectID)
.filter({ $0.roomID == room.id })
.rooms
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
.first!
} onSuccess: { container in
DuctSizingView.RoomRow(projectID: projectID, room: container)
} 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())
}
}
}
}
@@ -581,9 +617,9 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
try await database.projects.getCompletedSteps(projectID),
try await database.calculateDuctSizes(projectID: projectID)
)
} onSuccess: { (steps, rooms) in
} onSuccess: { (steps, ducts) in
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
DuctSizingView(rooms: rooms)
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
}
}
}

View File

@@ -3,52 +3,46 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Load component losses when view appears??
struct ComponentPressureLossesView: HTML, Sendable {
let componentPressureLosses: [ComponentPressureLoss]
let projectID: Project.ID
private var total: Double {
componentPressureLosses.reduce(into: 0) { $0 += $1.value }
componentPressureLosses.total
}
private var sortedLosses: [ComponentPressureLoss] {
componentPressureLosses.sorted {
$0.value > $1.value
}
}
var body: some HTML {
div(.class("space-y-4")) {
Row {
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
LabeledContent("Total") {
Badge(number: total)
}
PlusButton()
.attributes(
.class("btn-primary text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
.tooltip("Add component loss")
}
.attributes(.class("px-4"))
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl font-bold")) {
th { "Name" }
th { "Value" }
th {
div(.class("flex justify-end mx-auto")) {
Tooltip("Add Component Loss") {
PlusButton()
.attributes(
.class("btn-ghost text-2xl me-2"),
.showModal(id: ComponentLossForm.id())
)
}
}
}
}
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 componentPressureLosses {
TableRow(row: row)
}
}
tbody {
for row in sortedLosses {
TableRow(row: row)
}
}
}
}

View File

@@ -3,151 +3,53 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
// TODO: Add error text if prior steps are not completed.
struct DuctSizingView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
// let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
let trunks: [DuctSizing.TrunkContainer]
var body: some HTML {
div(.class("space-y-4")) {
PageTitle { "Duct Sizes" }
if rooms.count == 0 {
p(.class("text-error italic")) {
"Must complete all the previous sections to display duct sizing calculations."
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"))
}
} else {
RoomsTable(projectID: projectID, rooms: rooms)
}
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)
}
}
struct RoomsTable: HTML, Sendable {
let projectID: Project.ID
let rooms: [DuctSizing.RoomContainer]
var body: some HTML<HTMLTag.div> {
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr(.class("text-xl text-gray-400 font-bold")) {
th { "ID" }
th { "Name" }
th { "H-BTU" }
th { "C-BTU" }
th(.class("hidden 2xl:table-cell")) { "Htg CFM" }
th(.class("hidden 2xl:table-cell")) { "Clg CFM" }
th { "Dsn CFM" }
th(.class("hidden 2xl:table-cell")) { "Round Size" }
th { "Velocity" }
th { "Final Size" }
th { "Flex Size" }
th { "Width" }
th { "Height" }
}
}
tbody {
for room in rooms {
RoomRow(projectID: projectID, room: room)
}
}
}
}
}
}
struct RoomRow: HTML, Sendable {
let projectID: Project.ID
let room: DuctSizing.RoomContainer
var route: String {
SiteRoute.View.router.path(
for: .project(.detail(projectID, .ductSizing(.index)))
)
.appendingPath("room")
.appendingPath(room.roomID)
}
var body: some HTML<HTMLTag.tr> {
tr(.class("text-lg items-baseline"), .id(room.roomID.idString)) {
td { room.registerID }
td { room.roomName }
td { Number(room.heatingLoad, digits: 0) }
td { Number(room.coolingLoad, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.heatingCFM, digits: 0) }
td(.class("hidden 2xl:table-cell")) { Number(room.coolingCFM, digits: 0) }
td {
Badge(number: room.designCFM.value, digits: 0)
.attributes(.class("badge-\(room.designCFM.color)"))
}
td(.class("hidden 2xl:table-cell")) { Number(room.roundSize, digits: 1) }
td { Number(room.velocity) }
td {
Badge(number: room.finalSize)
.attributes(.class("badge-secondary"))
}
td {
Number(room.flexSize)
.attributes(.class("badge badge-outline badge-primary text-xl font-bold"))
}
td {
if let width = room.rectangularWidth {
Number(width)
}
}
td {
div(.class("flex justify-between items-center space-x-4")) {
div(.id("height_\(room.roomID.idString)"), .class("h-full my-auto")) {
if let height = room.rectangularSize?.height {
Number(height)
}
}
div {
div(.class("join")) {
// FIX: Delete rectangular size from room.
TrashButton()
.attributes(.class("join-item btn-ghost"))
.attributes(
.hx.delete(
route: .project(
.detail(
projectID,
.ductSizing(
.deleteRectangularSize(
room.roomID,
room.rectangularSize?.id ?? .init())
)
)
)
),
.hx.target("closest tr"),
.hx.swap(.outerHTML),
when: room.rectangularSize != nil
)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: RectangularSizeForm.id(room))
)
}
}
}
RectangularSizeForm(projectID: projectID, room: room)
}
}
}
}
}
extension DuctSizing.DesignCFM {
var color: String {
switch self {
case .heating: return "error"
case .cooling: return "info"
}
}
}

View File

@@ -5,56 +5,25 @@ import Styleguide
struct RectangularSizeForm: HTML, Sendable {
static func id(_ roomID: Room.ID? = nil) -> String {
let base = "rectangularSizeForm"
guard let roomID else { return base }
return "\(base)_\(roomID.idString)"
}
static func id(_ room: DuctSizing.RoomContainer) -> String {
return id(room.roomID)
let base = "rectangularSize"
return "\(base)_\(room.roomName.idString)"
}
let projectID: Project.ID
let roomID: Room.ID
let rectangularSizeID: DuctSizing.RectangularDuct.ID?
let register: Int
let height: Int?
@Environment(ProjectViewValue.$projectID) var projectID
let id: String
let room: DuctSizing.RoomContainer
let dismiss: Bool
init(
projectID: Project.ID,
roomID: Room.ID,
rectangularSizeID: DuctSizing.RectangularDuct.ID? = nil,
register: Int,
height: Int? = nil,
dismiss: Bool = true
) {
self.projectID = projectID
self.roomID = roomID
self.rectangularSizeID = rectangularSizeID
self.register = register
self.height = height
self.dismiss = dismiss
}
init(
projectID: Project.ID,
id: String? = nil,
room: DuctSizing.RoomContainer,
dismiss: Bool = true
) {
let register =
room.rectangularSize?.register
?? (Int("\(room.roomName.last!)") ?? 1)
self.init(
projectID: projectID,
roomID: room.roomID,
rectangularSizeID: room.rectangularSize?.id,
register: register,
height: room.rectangularSize?.height,
dismiss: dismiss
)
self.id = Self.id(room)
self.room = room
self.dismiss = dismiss
}
var route: String {
@@ -62,23 +31,30 @@ struct RectangularSizeForm: HTML, Sendable {
for: .project(.detail(projectID, .ductSizing(.index)))
)
.appendingPath("room")
.appendingPath(roomID)
.appendingPath(room.roomID)
}
var body: some HTML {
ModalForm(id: Self.id(roomID), dismiss: dismiss) {
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("closest tr"),
.hx.target("#\(rowID)"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("register"), .value(register))
input(.class("hidden"), .name("id"), .value(rectangularSizeID))
input(.class("hidden"), .name("register"), .value(room.roomRegister))
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id))
LabeledInput(
"Height",

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

@@ -200,6 +200,14 @@ struct EffectiveLengthForm: HTML, Sendable {
}
}
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 {
@@ -323,7 +331,7 @@ struct GroupTypeSelect: HTML, Sendable {
let selected: EffectiveLength.EffectiveLengthType
var body: some HTML<HTMLTag.label> {
label(.class("select")) {
label(.class("select w-full")) {
span(.class("label")) { "Type" }
select(.name("type"), .id("type")) {
for value in EffectiveLength.EffectiveLengthType.allCases {
@@ -346,7 +354,7 @@ extension EffectiveLength.EffectiveLengthType {
case .return:
return [5, 6, 7, 8, 10, 11, 12]
case .supply:
return [1, 2, 4, 8, 9, 11, 12]
return [1, 2, 3, 4, 8, 9, 11, 12]
}
}
}

View File

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

View File

@@ -21,136 +21,21 @@ struct EffectiveLengthsView: HTML, Sendable {
var body: some HTML {
div(.class("space-y-4")) {
Row {
PageTitleRow {
PageTitle { "Equivalent Lengths" }
PlusButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.showModal(id: EffectiveLengthForm.id(nil))
)
.tooltip("Add equivalent length")
}
.attributes(.class("pb-6"))
EffectiveLengthForm(projectID: projectID, dismiss: true)
div {
h2(.class("text-xl font-bold pb-4")) { "Supplies" }
.attributes(.class("hidden"), when: supplies.count == 0)
EffectiveLengthsTable(effectiveLengths: effectiveLengths)
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) {
for row in supplies {
EffectiveLengthView(effectiveLength: row)
}
}
}
div {
h2(.class("text-xl font-bold pb-4")) { "Returns" }
.attributes(.class("hidden"), when: returns.count == 0)
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 space-x-4 space-y-4")) {
for row in returns {
EffectiveLengthView(effectiveLength: row)
}
}
}
}
}
private struct EffectiveLengthView: HTML, Sendable {
let effectiveLength: EffectiveLength
var straightLengthsTotal: Int {
effectiveLength.straightLengths
.reduce(into: 0) { $0 += $1 }
}
var groupsTotal: Double {
effectiveLength.groups.totalEquivalentLength
}
var id: String {
return "effectiveLenghtCard_\(effectiveLength.id.uuidString.replacing("-", with: ""))"
}
var body: some HTML<HTMLTag.div> {
div(
.class("card h-full bg-base-100 shadow-sm border rounded-lg"),
.id(id)
) {
div(.class("card-body text-lg")) {
Row {
h2 { effectiveLength.name }
div(
.class("space-x-4")
) {
span(.class("text-primary text-sm italic")) {
"Total"
}
Number(self.effectiveLength.totalEquivalentLength, digits: 0)
.attributes(.class("badge badge-outline badge-primary text-lg"))
}
}
.attributes(.class("card-title pb-6"))
Row {
Label { "Straight Lengths" }
ul {
for length in effectiveLength.straightLengths {
li {
Number(length)
}
}
}
}
.attributes(.class("pb-6"))
Row {
span { "Groups" }
span { "Equivalent Length" }
span { "Quantity" }
}
.attributes(.class("label font-bold border-b border-label"))
for group in effectiveLength.groups {
Row {
span { "\(group.group)-\(group.letter)" }
Number(group.value)
Number(group.quantity)
}
}
div(.class("card-actions justify-end pt-6 space-y-4 mt-auto")) {
div(.class("join")) {
TrashButton()
.attributes(
.class("join-item btn-ghost"),
.hx.delete(
route: .project(
.detail(
effectiveLength.projectID,
.equivalentLength(.delete(id: effectiveLength.id))
)
)
),
.hx.confirm("Are you sure?"),
.hx.target("#\(id)"),
.hx.swap(.outerHTML)
)
EditButton()
.attributes(
.class("join-item btn-ghost"),
.showModal(id: EffectiveLengthForm.id(effectiveLength))
)
}
}
EffectiveLengthForm(effectiveLength: effectiveLength)
}
}
}
}
}

View File

@@ -7,8 +7,9 @@ struct EquipmentInfoForm: HTML, Sendable {
static let id = "equipmentForm"
@Environment(ProjectViewValue.$projectID) var projectID
let dismiss: Bool
let projectID: Project.ID
let equipmentInfo: EquipmentInfo?
var staticPressure: String {
@@ -33,7 +34,7 @@ struct EquipmentInfoForm: HTML, Sendable {
equipmentInfo != nil
? .hx.patch(route)
: .hx.post(route),
.hx.target("#equipmentInfo"),
.hx.target("body"),
.hx.swap(.outerHTML)
) {
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))

View File

@@ -12,16 +12,15 @@ struct EquipmentInfoView: HTML, Sendable {
.id("equipmentInfo")
) {
Row {
PageTitle { "Equipment Info" }
PageTitleRow {
PageTitle { "Equipment Details" }
Tooltip("Edit equipment info") {
EditButton()
.attributes(
.class("btn-ghost"),
.showModal(id: EquipmentInfoForm.id)
)
}
EditButton()
.attributes(
.class("btn-primary"),
.showModal(id: EquipmentInfoForm.id)
)
.tooltip("Edit equipment details")
}
if let equipmentInfo {
@@ -56,7 +55,8 @@ struct EquipmentInfoView: HTML, Sendable {
}
}
EquipmentInfoForm(
dismiss: true, projectID: projectID, equipmentInfo: equipmentInfo
dismiss: equipmentInfo != nil,
equipmentInfo: equipmentInfo
)
}
}

View File

@@ -3,8 +3,6 @@ import ManualDClient
import ManualDCore
import Styleguide
// FIX: Need to update available static, etc. when equipment info is submitted.
struct FrictionRateView: HTML, Sendable {
@Environment(ProjectViewValue.$projectID) var projectID
@@ -13,16 +11,20 @@ struct FrictionRateView: HTML, Sendable {
let equivalentLengths: EffectiveLength.MaxContainer
let frictionRateResponse: ManualDClient.FrictionRateResponse?
var availableStaticPressure: Double? {
private var availableStaticPressure: Double? {
frictionRateResponse?.availableStaticPressure
}
var frictionRateDesignValue: Double? {
private var frictionRateDesignValue: Double? {
frictionRateResponse?.frictionRate
}
var badgeColor: String {
let base = "badge-primary"
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"
@@ -30,72 +32,117 @@ struct FrictionRateView: HTML, Sendable {
return base
}
var showHighErrors: Bool {
private var showHighErrors: Bool {
guard let frictionRateDesignValue else { return false }
return frictionRateDesignValue >= 0.18
}
var showLowErrors: Bool {
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("space-y-6")) {
div(.class("grid grid-cols-2 px-4")) {
PageTitleRow {
div(.class("grid grid-cols-2 px-4 w-full")) {
PageTitle { "Friction Rate" }
PageTitle { "Friction Rate" }
div(.class("space-y-4 justify-end")) {
div(.class("space-y-2 justify-end font-bold text-lg")) {
if shouldShowBadges {
if let frictionRateDesignValue {
LabeledContent("Friction Rate Design Value") {
Badge(number: frictionRateDesignValue, digits: 2)
.attributes(.class("\(badgeColor)"))
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"))
}
.attributes(.class("justify-end"))
}
if let availableStaticPressure {
LabeledContent("Available Static Pressure") {
Badge(number: availableStaticPressure, digits: 2)
div(.class("text-error font-bold italic col-span-2")) {
Alert {
p {
"Must complete previous sections."
}
}
.attributes(.class("justify-end"))
.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)
}
}
div(.class("text-error italic")) {
p {
"No component pressures losses"
}
.attributes(.class("hidden"), when: componentLosses.totalComponentPressureLoss > 0)
p {
"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)"
}
.attributes(.class("hidden"), when: !showLowErrors)
p {
"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)"
}
.attributes(.class("hidden"), when: !showHighErrors)
}
div(.class("divider")) {}
ComponentPressureLossesView(
componentPressureLosses: componentLosses, projectID: projectID
)

View File

@@ -1,5 +1,6 @@
import Elementary
import ElementaryHTMX
import Foundation
import ManualDCore
import Styleguide
@@ -10,18 +11,44 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
let inner: Inner
let theme: Theme?
let displayFooter: Bool
init(
displayFooter: Bool = true,
theme: Theme? = nil,
_ inner: () -> Inner
) {
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"))
@@ -54,8 +81,29 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
}
public var body: some HTML {
div(.class("h-screen w-full")) {
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"
}
}
}
}
}
}
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
}

View File

@@ -24,31 +24,28 @@ struct Navbar: HTML, Sendable {
) {
div(.class("flex flex-1 space-x-4 items-center")) {
if sidebarToggle {
Tooltip("Open sidebar", position: .right) {
label(
.for("my-drawer-1"),
.class("size-7"),
.init(name: "aria-label", value: "open sidebar")
) {
SVG(.sidebarToggle)
}
.navButton()
}
}
Tooltip("Home", position: .right) {
a(
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
.href(route: .project(.index))
label(
.for("my-drawer-1"),
.class("size-7"),
.init(name: "aria-label", value: "open sidebar")
) {
img(
.src("/images/mand_logo_sm.webp"),
)
span { "Duct Calc" }
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
@@ -59,34 +56,7 @@ struct Navbar: HTML, Sendable {
SVG(.circleUser)
}
.navButton()
// details(.class("dropdown dropdown-left dropdown-bottom")) {
// summary(.class("btn w-fit px-4 py-2")) {
// SVG(.circleUser)
// }
// .navButton()
//
// ul(
// .class(
// """
// menu dropdown-content bg-base-100
// rounded-box z-1 w-fit p-2 shadow-sm
// """
// )
// ) {
// li(.class("w-full")) {
// // TODO: Save theme to user profile ??
// div(.class("flex justify-between p-4 space-x-6")) {
// Label("Theme")
// input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
// }
// }
// }
//
// // button(.class("w-fit px-4 py-2")) {
// // SVG(.circleUser)
// // }
// // .navButton()
// }
.tooltip("Profile")
}
}
}

View File

@@ -8,56 +8,56 @@ struct ProjectDetail: HTML, Sendable {
var body: some HTML {
div {
Row {
h1(.class("text-3xl font-bold")) { "Project" }
PageTitleRow {
PageTitle { "Project" }
EditButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.on(.click, "projectForm.showModal()")
)
.tooltip("Edit project", position: .left)
}
div(.class("overflow-x-auto")) {
table(.class("table table-zebra text-lg")) {
tbody {
tr {
td(.class("label font-bold")) { "Name" }
td {
div(.class("flex justify-end")) {
project.name
}
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")) { "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")) { "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")) { "State" }
td {
div(.class("flex justify-end")) {
project.state
}
}
tr {
td(.class("label font-bold")) { "Zip" }
td {
div(.class("flex justify-end")) {
project.zipCode
}
}
tr {
td(.class("label font-bold")) { "Zip" }
td {
div(.class("flex justify-end")) {
project.zipCode
}
}
}

View File

@@ -31,25 +31,22 @@ struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
}
var body: some HTML {
div(.class("h-screen w-full")) {
div(.class("drawer lg:drawer-open h-full")) {
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
div(.class("drawer lg:drawer-open")) {
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
div(.class("drawer-content")) {
Navbar(sidebarToggle: true)
div(.class("p-4")) {
inner
.environment(ProjectViewValue.$projectID, projectID)
}
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
)
}
Sidebar(
active: activeTab,
projectID: projectID,
completedSteps: completedSteps
)
}
}
@@ -65,7 +62,7 @@ extension ProjectView {
var body: some HTML {
div(.class("drawer-side is-drawer-close:overflow-visible")) {
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")
@@ -74,14 +71,13 @@ extension ProjectView {
div(
.class(
"""
flex min-h-full flex-col items-start bg-base-300 text-base-content
flex grow h-full flex-col items-start bg-base-300 text-base-content
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]
"""
)
) {
ul(.class("w-full")) {
ul(.class("w-full grow")) {
li(.class("flex w-full")) {
row(

View File

@@ -19,36 +19,34 @@ struct ProjectsTable: HTML, Sendable {
div {
Navbar(sidebarToggle: false)
div(.class("m-6")) {
Row {
PageTitleRow {
PageTitle { "Projects" }
Tooltip("Add project") {
PlusButton()
.attributes(
.class("btn-ghost"),
.class("btn-primary"),
.showModal(id: ProjectForm.id)
)
}
}
.attributes(.class("pb-6"))
div(.class("overflow-x-auto")) {
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
Rows(projects: projects)
table(.class("table table-zebra")) {
thead {
tr {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
Rows(projects: projects)
}
}
ProjectForm(dismiss: true)
}
ProjectForm(dismiss: true)
}
}
}

View File

@@ -13,108 +13,104 @@ struct RoomsView: HTML, Sendable {
var body: some HTML {
div(.class("flex w-full flex-col")) {
Row {
PageTitle { "Room Loads" }
PageTitleRow {
div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
div(.class("flex justify-end items-end -my-2")) {
Tooltip("Project wide sensible heat ratio", position: .left) {
button(
.class(
"""
justify-end items-end p-4
hover:bg-neutral hover:text-white hover:rounded-lg
"""
),
.showModal(id: SHRForm.id)
) {
LabeledContent {
div(.class("flex justify-end items-end space-x-4")) {
Label {
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"
}
.attributes(.class("me-8"), when: sensibleHeatRatio == nil)
}
} content: {
if let sensibleHeatRatio {
Badge(number: sensibleHeatRatio)
} else {
SVG(.squarePen)
}
}
}
.attributes(.class("border rounded-lg border-error"), when: sensibleHeatRatio == nil)
}
}
}
div(.class("flex flex-wrap justify-between mt-6")) {
div(.class("flex items-end space-x-4")) {
Label { "Heating Total" }
Badge(number: rooms.heatingTotal, digits: 0)
.attributes(.class("badge-error"))
}
div(.class("flex items-end space-x-4")) {
Label { "Cooling Total" }
Badge(number: rooms.coolingTotal, digits: 0)
.attributes(.class("badge-success"))
}
div(.class("flex justify-end items-end space-x-4 me-4")) {
Label { "Cooling Sensible" }
Badge(number: rooms.coolingSensible(shr: sensibleHeatRatio), digits: 0)
.attributes(.class("badge-info"))
}
}
// .attributes(.class("mt-6 me-4"))
div(.class("divider")) {}
SHRForm(projectID: projectID, sensibleHeatRatio: sensibleHeatRatio)
div(.class("overflow-x-auto")) {
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-ghost mx-auto"),
.showModal(id: RoomForm.id())
)
.attributes(.class("tooltip-left"))
if let sensibleHeatRatio {
Badge(number: sensibleHeatRatio)
} else {
Badge { "set" }
}
}
}
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
}
}
tbody {
for room in rooms {
RoomRow(room: room, shr: sensibleHeatRatio)
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"))
}
}
}
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)
}
}
}
@@ -201,8 +197,9 @@ struct RoomsView: HTML, Sendable {
struct SHRForm: HTML, Sendable {
static let id = "shrForm"
let projectID: Project.ID
@Environment(ProjectViewValue.$projectID) var projectID
let sensibleHeatRatio: Double?
let dismiss: Bool
var route: String {
SiteRoute.View.router
@@ -211,7 +208,7 @@ struct RoomsView: HTML, Sendable {
}
var body: some HTML {
ModalForm(id: Self.id, dismiss: true) {
ModalForm(id: Self.id, dismiss: dismiss) {
h1(.class("text-xl font-bold mb-6")) {
"Sensible Heat Ratio"
}

View File

@@ -2,9 +2,30 @@ 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 {
UserProfileForm(userID: UUID(0), profile: nil, dismiss: false)
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

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