-
-
Notifications
You must be signed in to change notification settings - Fork 735
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add lifecycle summary info read model + average time spent in l…
…ifecycle query (#8691) This PR adds a project lifecycle read model file along with the most important (and most complicated) query that runs with it: calculating the average time spent in each stage. The calculation relies on the following: - when calculating the average of a stage, only flags who have gone into a following stage are taken into account. - we'll count "next stage" as the next row for the same feature where the `created_at` timestamp is higher than the current row - if you skip a stage (go straight to live or archived, for instance), that doesn't matter, because we don't look at that. The UI only shows the time spent in days, so I decided to go with rounding to days directly in the query. ## Discussion point: This one uses a subquery, but I'm not sure it's possible to do without it. However, if it's too expensive, we can probably also cache the value somehow, so it's not calculated more than every so often.
- Loading branch information
1 parent
8a507b2
commit e07aab6
Showing
3 changed files
with
245 additions
and
4 deletions.
There are no files selected for viewing
105 changes: 105 additions & 0 deletions
105
src/lib/features/project-status/project-lifecycle-summary-read-model.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import { addDays } from 'date-fns'; | ||
import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; | ||
import getLogger from '../../../test/fixtures/no-logger'; | ||
import { ProjectLifecycleSummaryReadModel } from './project-lifecycle-summary-read-model'; | ||
import type { StageName } from '../../types'; | ||
import { randomId } from '../../util'; | ||
|
||
let db: ITestDb; | ||
|
||
beforeAll(async () => { | ||
db = await dbInit('project_lifecycle_summary_read_model_serial', getLogger); | ||
}); | ||
|
||
afterAll(async () => { | ||
if (db) { | ||
await db.destroy(); | ||
} | ||
}); | ||
|
||
const updateFeatureStageDate = async ( | ||
flagName: string, | ||
stage: string, | ||
newDate: Date, | ||
) => { | ||
await db | ||
.rawDatabase('feature_lifecycles') | ||
.where({ feature: flagName, stage: stage }) | ||
.update({ created_at: newDate }); | ||
}; | ||
|
||
describe('Average time calculation', () => { | ||
test('it calculates the average time for each stage', async () => { | ||
const project1 = await db.stores.projectStore.create({ | ||
name: 'project1', | ||
id: 'project1', | ||
}); | ||
const now = new Date(); | ||
|
||
const flags = [ | ||
{ name: randomId(), offsets: [2, 5, 6, 10] }, | ||
{ name: randomId(), offsets: [1, null, 4, 7] }, | ||
{ name: randomId(), offsets: [12, 25, 8, 9] }, | ||
{ name: randomId(), offsets: [1, 2, 3, null] }, | ||
]; | ||
|
||
for (const { name, offsets } of flags) { | ||
const created = await db.stores.featureToggleStore.create( | ||
project1.id, | ||
{ | ||
name, | ||
createdByUserId: 1, | ||
}, | ||
); | ||
await db.stores.featureLifecycleStore.insert([ | ||
{ | ||
feature: name, | ||
stage: 'initial', | ||
}, | ||
]); | ||
|
||
const stages = ['pre-live', 'live', 'completed', 'archived']; | ||
for (const [index, stage] of stages.entries()) { | ||
const offset = offsets[index]; | ||
if (offset === null) { | ||
continue; | ||
} | ||
|
||
const offsetFromInitial = offsets | ||
.slice(0, index + 1) | ||
.reduce((a, b) => (a ?? 0) + (b ?? 0), 0) as number; | ||
|
||
await db.stores.featureLifecycleStore.insert([ | ||
{ | ||
feature: created.name, | ||
stage: stage as StageName, | ||
}, | ||
]); | ||
|
||
await updateFeatureStageDate( | ||
created.name, | ||
stage, | ||
addDays(now, offsetFromInitial), | ||
); | ||
} | ||
} | ||
|
||
const readModel = new ProjectLifecycleSummaryReadModel(db.rawDatabase); | ||
|
||
const result = await readModel.getAverageTimeInEachStage(project1.id); | ||
|
||
expect(result).toMatchObject({ | ||
initial: 4, // (2 + 1 + 12 + 1) / 4 = 4 | ||
'pre-live': 9, // (5 + 25 + 2 + 4) / 4 = 9 | ||
live: 6, // (6 + 8 + 3) / 3 ~= 5.67 ~= 6 | ||
completed: 9, // (10 + 7 + 9) / 3 ~= 8.67 ~= 9 | ||
}); | ||
}); | ||
|
||
test('it returns `null` if it has no data for something', async () => {}); | ||
test('it rounds to the nearest whole number', async () => {}); | ||
test('it ignores flags in other projects', async () => {}); | ||
test('it ignores flags in other projects', async () => {}); | ||
|
||
test("it ignores rows that don't have a next stage", async () => {}); | ||
}); |
136 changes: 136 additions & 0 deletions
136
src/lib/features/project-status/project-lifecycle-summary-read-model.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,136 @@ | ||
import * as permissions from '../../types/permissions'; | ||
import type { Db } from '../../db/db'; | ||
|
||
const { ADMIN } = permissions; | ||
|
||
export type IProjectLifecycleSummaryReadModel = {}; | ||
|
||
type ProjectLifecycleSummary = { | ||
initial: { | ||
averageDays: number; | ||
currentFlags: number; | ||
}; | ||
preLive: { | ||
averageDays: number; | ||
currentFlags: number; | ||
}; | ||
live: { | ||
averageDays: number; | ||
currentFlags: number; | ||
}; | ||
completed: { | ||
averageDays: number; | ||
currentFlags: number; | ||
}; | ||
archived: { | ||
currentFlags: number; | ||
archivedFlagsOverLastMonth: number; | ||
}; | ||
}; | ||
|
||
export class ProjectLifecycleSummaryReadModel | ||
implements IProjectLifecycleSummaryReadModel | ||
{ | ||
private db: Db; | ||
|
||
constructor(db: Db) { | ||
this.db = db; | ||
} | ||
|
||
async getAverageTimeInEachStage(projectId: string): Promise<{ | ||
initial: number; | ||
'pre-live': number; | ||
live: number; | ||
completed: number; | ||
}> { | ||
const q = this.db | ||
.with( | ||
'stage_durations', | ||
this.db('feature_lifecycles as fl1') | ||
.select( | ||
'fl1.feature', | ||
'fl1.stage', | ||
this.db.raw( | ||
'EXTRACT(EPOCH FROM (MIN(fl2.created_at) - fl1.created_at)) / 86400 AS days_in_stage', | ||
), | ||
) | ||
.join('feature_lifecycles as fl2', function () { | ||
this.on('fl1.feature', '=', 'fl2.feature').andOn( | ||
'fl2.created_at', | ||
'>', | ||
'fl1.created_at', | ||
); | ||
}) | ||
.innerJoin('features as f', 'fl1.feature', 'f.name') | ||
.where('f.project', projectId) | ||
.whereNot('fl1.stage', 'archived') | ||
.groupBy('fl1.feature', 'fl1.stage'), | ||
) | ||
.select('stage_durations.stage') | ||
.select( | ||
this.db.raw('ROUND(AVG(days_in_stage)) AS avg_days_in_stage'), | ||
) | ||
.from('stage_durations') | ||
.groupBy('stage_durations.stage'); | ||
|
||
const result = await q; | ||
return result.reduce( | ||
(acc, row) => { | ||
acc[row.stage] = Number(row.avg_days_in_stage); | ||
return acc; | ||
}, | ||
{ | ||
initial: 0, | ||
'pre-live': 0, | ||
live: 0, | ||
completed: 0, | ||
}, | ||
); | ||
} | ||
|
||
async getCurrentFlagsInEachStage(projectId: string) { | ||
return 0; | ||
} | ||
|
||
async getArchivedFlagsOverLastMonth(projectId: string) { | ||
return 0; | ||
} | ||
|
||
async getProjectLifecycleSummary( | ||
projectId: string, | ||
): Promise<ProjectLifecycleSummary> { | ||
const [ | ||
averageTimeInEachStage, | ||
currentFlagsInEachStage, | ||
archivedFlagsOverLastMonth, | ||
] = await Promise.all([ | ||
this.getAverageTimeInEachStage(projectId), | ||
this.getCurrentFlagsInEachStage(projectId), | ||
this.getArchivedFlagsOverLastMonth(projectId), | ||
]); | ||
|
||
// collate the data | ||
return { | ||
initial: { | ||
averageDays: 0, | ||
currentFlags: 0, | ||
}, | ||
preLive: { | ||
averageDays: 0, | ||
currentFlags: 0, | ||
}, | ||
live: { | ||
averageDays: 0, | ||
currentFlags: 0, | ||
}, | ||
completed: { | ||
averageDays: 0, | ||
currentFlags: 0, | ||
}, | ||
archived: { | ||
currentFlags: 0, | ||
archivedFlagsOverLastMonth: 0, | ||
}, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters