Skip to content

Commit

Permalink
compute counts in backend to improve load speeds
Browse files Browse the repository at this point in the history
  • Loading branch information
ghackenberg committed May 2, 2024
1 parent 09ed712 commit 24bce65
Show file tree
Hide file tree
Showing 15 changed files with 77 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@ export class CommentService implements CommentREST {
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = comment.updated
await Database.get().productRepository.save(product)
// Find milestone
const milestones = issue.milestoneId ? await Database.get().milestoneRepository.findBy({ milestoneId: issue.milestoneId }) : []
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment] })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment], milestones })
// Notify changes
this.notifyComment(product, issue, comment, 'Comment notification (add)')
// Return comment
Expand All @@ -80,8 +82,10 @@ export class CommentService implements CommentREST {
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = comment.updated
await Database.get().productRepository.save(product)
// Find milestone
const milestones = issue.milestoneId ? await Database.get().milestoneRepository.findBy({ milestoneId: issue.milestoneId }) : []
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment] })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment], milestones })
// Notify changes
this.notifyComment(product, issue, comment, 'Comment notification (update)')
// Return comment
Expand All @@ -103,8 +107,10 @@ export class CommentService implements CommentREST {
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = comment.updated
await Database.get().productRepository.save(product)
// Find milestone
const milestones = issue.milestoneId ? await Database.get().milestoneRepository.findBy({ milestoneId: issue.milestoneId }) : []
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment] })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments: [comment], milestones })
// Return comment
return convertComment(comment)
}
Expand Down
25 changes: 20 additions & 5 deletions packages/backend/scripts/src/modules/rest/issues/issue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import shortid from 'shortid'
import { IsNull } from 'typeorm'

import { IssueCreate, IssueREST, IssueRead, IssueUpdate, ProductRead } from 'productboard-common'
import { Database, convertIssue } from 'productboard-database'
import { Database, MilestoneEntity, convertIssue } from 'productboard-database'

import { emitProductMessage } from '../../../functions/emit'
import { TRANSPORTER } from '../../../functions/mail'
Expand Down Expand Up @@ -41,8 +41,10 @@ export class IssueService implements IssueREST {
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = issue.updated
await Database.get().productRepository.save(product)
// Find milestone
const milestones = data.milestoneId ? await Database.get().milestoneRepository.findBy({ milestoneId: data.milestoneId }) : []
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue] })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], milestones })
// Notify changes
this.notifyIssue(product, issue, 'Issue notification (add)')
// Return issue
Expand All @@ -55,8 +57,19 @@ export class IssueService implements IssueREST {
}

async updateIssue(productId: string, issueId: string, data: IssueUpdate): Promise<IssueRead> {
// Update issue
// Find issue
const issue = await Database.get().issueRepository.findOneByOrFail({ productId, issueId })
// Find milestone
const milestones: MilestoneEntity[] = []
if (issue.milestoneId != data.milestoneId) {
if (issue.milestoneId) {
milestones.push(await Database.get().milestoneRepository.findOneBy({ milestoneId: issue.milestoneId }))
}
if (data.milestoneId) {
milestones.push(await Database.get().milestoneRepository.findOneBy({ milestoneId: data.milestoneId }))
}
}
// Update issue
issue.updated = Date.now()
issue.assignedUserIds = data.assignedUserIds
issue.label = data.label
Expand All @@ -67,7 +80,7 @@ export class IssueService implements IssueREST {
product.updated = issue.updated
await Database.get().productRepository.save(product)
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue] })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], milestones })
// Notify changes
this.notifyIssue(product, issue, 'Issue notification (update)')
// Return issue
Expand All @@ -91,8 +104,10 @@ export class IssueService implements IssueREST {
comment.updated = issue.updated
await Database.get().commentRepository.save(comment)
}
// Find milestone
const milestones = issue.milestoneId ? await Database.get().milestoneRepository.findBy({ milestoneId: issue.milestoneId }) : []
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments })
emitProductMessage(productId, { type: 'patch', products: [product], issues: [issue], comments, milestones })
// Return issue
return convertIssue(issue)
}
Expand Down
4 changes: 3 additions & 1 deletion packages/common/src/data/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export class ProductRead extends ProductCreate {
@ApiProperty()
closedIssueCount?: number
@ApiProperty()
milestoneCount?: number
openMilestoneCount?: number
@ApiProperty()
closedMilestoneCount?: number
@ApiProperty()
memberCount?: number
}
26 changes: 17 additions & 9 deletions packages/database/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ export async function convertUser(user: UserEntity, full: boolean): Promise<User
}

export async function convertProduct(product: ProductEntity): Promise<ProductRead> {

const productId = product.productId

const versionCount = await Database.get().versionRepository.countBy({ product, deleted: IsNull() })
const openIssueCount = await Database.get().issueRepository.countBy({ product, state: "open", deleted: IsNull() })
const closedIssueCount = await Database.get().issueRepository.countBy({ product, state: "closed", deleted: IsNull() })
const milestoneCount = await Database.get().milestoneRepository.countBy({ product, deleted: IsNull() })
const memberCount = await Database.get().memberRepository.countBy({ product, deleted: IsNull() })
const versionCount = await Database.get().versionRepository.countBy({ productId, deleted: IsNull() })
const openIssueCount = await Database.get().issueRepository.countBy({ productId, state: "open", deleted: IsNull() })
const closedIssueCount = await Database.get().issueRepository.countBy({ productId, state: "closed", deleted: IsNull() })
const openMilestoneCount = await Database.get().milestoneRepository.countBy({ productId, deleted: IsNull() })
const closedMilestoneCount = 0 // TODO compute closed milestone count
const memberCount = await Database.get().memberRepository.countBy({ productId, deleted: IsNull() })

return {
userId: product.userId,
Expand All @@ -46,7 +49,8 @@ export async function convertProduct(product: ProductEntity): Promise<ProductRea
versionCount,
openIssueCount,
closedIssueCount,
milestoneCount,
openMilestoneCount,
closedMilestoneCount,
memberCount
}
}
Expand All @@ -70,8 +74,10 @@ export async function convertVersion(version: VersionEntity): Promise<VersionRea
}

export async function convertIssue(issue: IssueEntity): Promise<IssueRead> {

const issueId = issue.issueId

const commentCount = await Database.get().commentRepository.countBy({ issue, deleted: IsNull() })
const commentCount = await Database.get().commentRepository.countBy({ issueId, deleted: IsNull() })
const attachmentCount = 0 // TODO compute attachment count
const partCount = 0 // TODO compute part count

Expand Down Expand Up @@ -122,8 +128,10 @@ export async function convertAttachment(attachment: AttachmentEntity): Promise<A

export async function convertMilestone(milestone: MilestoneEntity): Promise<MilestoneRead> {

const openIssueCount = await Database.get().issueRepository.countBy({ milestone, state: "open", deleted: IsNull() })
const closedIssueCount = await Database.get().issueRepository.countBy({ milestone, state: "closed", deleted: IsNull() })
const milestoneId = milestone.milestoneId

const openIssueCount = await Database.get().issueRepository.countBy({ milestoneId, state: "open", deleted: IsNull() })
const closedIssueCount = await Database.get().issueRepository.countBy({ milestoneId, state: "closed", deleted: IsNull() })

return {
userId: milestone.userId,
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/scripts/clients/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function notify<T>(callbacks: Index<Callback<T>[]>, id: string, value: T) {
}
}
function update<T extends Entity>(entityCache: Index<T>, childCache: Index<Index<boolean>>, entityCallbacks: Index<Callback<T>[]>, childCallbacks: Index<Callback<T[]>[]>, entityId: string, parentId: string, value: T) {
if (!(entityId in entityCache) || entityCache[entityId].updated < value.updated) {
if (!(entityId in entityCache) || entityCache[entityId].updated <= value.updated) {
// Update caches
entityCache[entityId] = value
if (parentId) {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/scripts/clients/mqtt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function init() {
// Forward message to handlers
client.on('message', (topic, payload) => {
const object = JSON.parse(payload.toString())
console.log(object)
for (const pattern in handlers) {
if (matches(pattern, topic)) {
for (const handler of handlers[pattern]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NavLink, useLocation } from 'react-router-dom'
import { ProductRead } from 'productboard-common'

import { useAsyncHistory } from '../../hooks/history'
import { useIssues } from '../../hooks/list'
import { PRODUCTS_4 } from '../../pattern'

import IssueIcon from '/src/images/issue.png'
Expand All @@ -14,10 +13,6 @@ export const IssuesLink = (props: {product: ProductRead}) => {
const { pathname } = useLocation()
const { go, goBack, replace } = useAsyncHistory()

// HOOKS

const issues = useIssues(props.product.productId, undefined, 'open')

// FUNCTIONS

async function handleClick(event: React.UIEvent) {
Expand All @@ -42,7 +37,7 @@ export const IssuesLink = (props: {product: ProductRead}) => {
<NavLink to={`/products/${props.product.productId}/issues`} onClick={handleClick}>
<img src={IssueIcon} className='icon small'/>
<span className='label'>Issues</span>
<span className='badge'>{issues ? issues.length : '?'}</span>
<span className='badge'>{props.product.openIssueCount}</span>
</NavLink>
</span>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NavLink, useLocation } from 'react-router-dom'
import { ProductRead } from 'productboard-common'

import { useAsyncHistory } from '../../hooks/history'
import { useMembers } from '../../hooks/list'
import { PRODUCTS_4 } from '../../pattern'

import MemberIcon from '/src/images/user.png'
Expand All @@ -14,10 +13,6 @@ export const MembersLink = (props: {product: ProductRead}) => {
const { pathname } = useLocation()
const { go, goBack, replace } = useAsyncHistory()

// HOOKS

const members = useMembers(props.product.productId)

// FUNCTIONS

async function handleClick(event: React.UIEvent) {
Expand All @@ -42,7 +37,7 @@ export const MembersLink = (props: {product: ProductRead}) => {
<NavLink to={`/products/${props.product.productId}/members`} onClick={handleClick}>
<img src={MemberIcon} className='icon small'/>
<span className='label'>Members</span>
<span className='badge'>{members ? members.length : '?'}</span>
<span className='badge'>{props.product.memberCount}</span>
</NavLink>
</span>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NavLink, useLocation } from 'react-router-dom'
import { ProductRead } from 'productboard-common'

import { useAsyncHistory } from '../../hooks/history'
import { useMilestones } from '../../hooks/list'
import { PRODUCTS_4 } from '../../pattern'

import MilestoneIcon from '/src/images/milestone.png'
Expand All @@ -14,10 +13,6 @@ export const MilestonesLink = (props: {product: ProductRead}) => {
const { pathname } = useLocation()
const { go, goBack, replace } = useAsyncHistory()

// HOOKS

const milestones = useMilestones(props.product.productId)

// FUNCTIONS

async function handleClick(event: React.UIEvent) {
Expand All @@ -42,7 +37,7 @@ export const MilestonesLink = (props: {product: ProductRead}) => {
<NavLink to={`/products/${props.product.productId}/milestones`} onClick={handleClick}>
<img src={MilestoneIcon} className='icon small'/>
<span className='label'>Milestones</span>
<span className='badge'>{milestones ? milestones.length : '?'}</span>
<span className='badge'>{props.product.openMilestoneCount}</span>
</NavLink>
</span>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NavLink, useLocation } from 'react-router-dom'
import { ProductRead } from 'productboard-common'

import { useAsyncHistory } from '../../hooks/history'
import { useVersions } from '../../hooks/list'
import { PRODUCTS_4 } from '../../pattern'

import VersionIcon from '/src/images/version.png'
Expand All @@ -14,10 +13,6 @@ export const VersionsLink = (props: {product: ProductRead}) => {
const { pathname } = useLocation()
const { go, goBack, replace } = useAsyncHistory()

// HOOKS

const versions = useVersions(props.product.productId)

// FUNCTIONS

async function handleClick(event: React.UIEvent) {
Expand All @@ -42,7 +37,7 @@ export const VersionsLink = (props: {product: ProductRead}) => {
<NavLink to={`/products/${props.product.productId}/versions`} onClick={handleClick}>
<img src={VersionIcon} className='icon small'/>
<span className='label'>Versions</span>
<span className='badge'>{versions ? versions.length : '?'}</span>
<span className='badge'>{props.product.versionCount}</span>
</NavLink>
</span>
)
Expand Down
9 changes: 3 additions & 6 deletions packages/frontend/src/scripts/components/views/Product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ import { LegalFooter } from '../snippets/LegalFooter'
import { Column, Table } from '../widgets/Table'
import { ProductImageWidget } from '../widgets/ProductImage'
import { ProductUserPicture } from '../values/ProductUserPicture'
import { MemberCount } from '../counts/Members'
import { IssueCount } from '../counts/Issues'
import { VersionCount } from '../counts/Versions'
import { LoadingView } from './Loading'

import ProductIcon from '/src/images/product.png'
Expand Down Expand Up @@ -74,17 +71,17 @@ export const ProductView = () => {
) },
{ label: 'Versions', class: 'center', content: product => (
<span className='badge'>
<VersionCount productId={product.productId}/>
{product.versionCount}
</span>
) },
{ label: 'Issues', class: 'center', content: product => (
<span className='badge'>
<IssueCount productId={product.productId} state='open'/>
{product.openIssueCount}
</span>
) },
{ label: 'Members', class: 'center', content: product => (
<span className='badge'>
<MemberCount productId={product.productId}/>
{product.memberCount}
</span>
) },
{ label: '🧑', class: 'center', content: product => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import { UserContext } from '../../contexts/User'
import { useProduct } from '../../hooks/entity'
import { useAsyncHistory } from '../../hooks/history'
import { useIssues, useMembers } from '../../hooks/list'
import { CommentCount } from '../counts/Comments'
import { IssueCount } from '../counts/Issues'
import { PartCount } from '../counts/Parts'
import { LegalFooter } from '../snippets/LegalFooter'
import { ProductFooter, ProductFooterItem } from '../snippets/ProductFooter'
Expand Down Expand Up @@ -114,7 +112,7 @@ export const ProductIssueView = () => {
) },
{ label: 'Comments', class: 'center nowrap', content: issue => (
<span className='badge'>
<CommentCount productId={productId} issueId={issue.issueId}/>
{issue.commentCount}
</span>
) },
{ label: 'Parts', class: 'center nowrap', content: issue => (
Expand Down Expand Up @@ -161,10 +159,10 @@ export const ProductIssueView = () => {
</NavLink>
)}
<NavLink to={`/products/${productId}/issues?state=open`} replace={true} className={`button ${state == 'open' ? 'fill' : 'stroke'} blue`}>
<strong>Open</strong> issues <span className='badge'><IssueCount productId={productId} state={'open'}/></span>
<strong>Open</strong> issues <span className='badge'>{ product.openIssueCount }</span>
</NavLink>
<NavLink to={`/products/${productId}/issues?state=closed`} replace={true} className={`button ${state == 'closed' ? 'fill' : 'stroke'} blue`}>
<strong>Closed</strong> issues <span className='badge'><IssueCount productId={productId} state={'closed'}/></span>
<strong>Closed</strong> issues <span className='badge'>{ product.closedIssueCount }</span>
</NavLink>
</div>
{ issues.filter(issue => issue.state == state).length == 0 ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { UserContext } from '../../contexts/User'
import { useProduct } from '../../hooks/entity'
import { useAsyncHistory } from '../../hooks/history'
import { useMilestones, useMembers } from '../../hooks/list'
import { IssueCount } from '../counts/Issues'
import { formatDateTime } from '../../functions/time'
import { LegalFooter } from '../snippets/LegalFooter'
import { ProductFooter, ProductFooterItem } from '../snippets/ProductFooter'
Expand Down Expand Up @@ -78,16 +77,16 @@ export const ProductMilestoneView = () => {
) },
{ label: 'Open', class: 'center', content: milestone => (
<span className='badge'>
<IssueCount productId={productId} milestoneId={milestone.milestoneId} state='open'/>
{milestone.openIssueCount}
</span>
) },
{ label: 'Closed', class: 'center', content: milestone => (
<span className='badge'>
<IssueCount productId={productId} milestoneId={milestone.milestoneId} state='closed'/>
{milestone.closedIssueCount}
</span>
) },
{ label: 'Progress', class: 'center', content: milestone => (
<MilestoneProgressWidget productId={productId} milestoneId={milestone.milestoneId}/>
<MilestoneProgressWidget milestone={milestone}/>
) },
{ label: '🛠️', class: 'center', content: milestone => (
<a onClick={event => deleteMilestone(event, milestone)}>
Expand Down
Loading

0 comments on commit 24bce65

Please sign in to comment.