Skip to content

Commit

Permalink
feat: Add recurring retros (#9311)
Browse files Browse the repository at this point in the history
* chore: Add recurring retros feature flag

* Make it an org flag

* feat: Recurring retros

* Only show the recurring settings if the feature flag is set

* Formatting

* Add processRecurrence test for retros

* Remove debug output

* Minor fixes

* Fix recurrence label colours in meeting dash
  • Loading branch information
Dschoordsch committed Jan 23, 2024
1 parent 73aac5f commit df2e992
Show file tree
Hide file tree
Showing 22 changed files with 338 additions and 113 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ const ActivityDetailsSidebar = (props: Props) => {
orgId
organization {
name
featureFlags {
recurringRetros
}
}
retroSettings: meetingSettings(meetingType: retrospective) {
...NewMeetingSettingsToggleCheckIn_settings
Expand Down Expand Up @@ -233,7 +236,14 @@ const ActivityDetailsSidebar = (props: Props) => {
if (type === 'retrospective') {
StartRetrospectiveMutation(
atmosphere,
{teamId: selectedTeam.id, gcalInput},
{
teamId: selectedTeam.id,
recurrenceSettings: {
rrule: recurrenceSettings.rrule?.toString(),
name: recurrenceSettings.name
},
gcalInput
},
{history, onError, onCompleted}
)
} else if (type === 'poker') {
Expand Down Expand Up @@ -394,6 +404,12 @@ const ActivityDetailsSidebar = (props: Props) => {
teamRef={selectedTeam}
/>
<NewMeetingSettingsToggleAnonymity settingsRef={selectedTeam.retroSettings} />
{selectedTeam.organization.featureFlags.recurringRetros && (
<ActivityDetailsRecurrenceSettings
onRecurrenceSettingsUpdated={setRecurrenceSettings}
recurrenceSettings={recurrenceSettings}
/>
)}
</>
)}
{type === 'poker' && (
Expand Down
43 changes: 21 additions & 22 deletions packages/client/components/MeetingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import IconLabel from './IconLabel'
import MeetingCardOptionsMenuRoot from './MeetingCardOptionsMenuRoot'
import useModal from '../hooks/useModal'
import {EndRecurringMeetingModal} from './TeamPrompt/Recurrence/EndRecurringMeetingModal'
import clsx from 'clsx'

const CardWrapper = styled('div')<{
maybeTabletPlus: boolean
Expand Down Expand Up @@ -118,6 +119,12 @@ const BACKGROUND_COLORS = {
poker: PALETTE.TOMATO_400,
teamPrompt: PALETTE.JADE_400
}
const RECURRING_LABEL_COLORS = {
retrospective: 'text-grape-600 bg-grape-100',
action: 'text-aqua-600 bg-aqua-300',
poker: 'text-tomato-600 bg-tomato-300',
teamPrompt: 'text-jade-600 bg-jade-300'
}
const MeetingImgBackground = styled.div<{meetingType: keyof typeof BACKGROUND_COLORS}>(
({meetingType}) => ({
background: BACKGROUND_COLORS[meetingType],
Expand All @@ -130,19 +137,6 @@ const MeetingImgBackground = styled.div<{meetingType: keyof typeof BACKGROUND_CO
})
)

const RecurringLabel = styled.span({
color: PALETTE.JADE_500,
background: PALETTE.JADE_300,
fontSize: 11,
lineHeight: '12px',
fontWeight: 500,
position: 'absolute',
right: 8,
top: 8,
padding: '4px 8px 4px 8px',
borderRadius: '64px'
})

const MeetingImgWrapper = styled('div')({
borderRadius: `${Card.BORDER_RADIUS}px ${Card.BORDER_RADIUS}px 0 0`,
display: 'block',
Expand Down Expand Up @@ -230,13 +224,11 @@ const MeetingCard = (props: Props) => {
...AvatarListUser_user
}
}
... on TeamPromptMeeting {
meetingSeries {
id
title
cancelledAt
recurrenceRule
}
meetingSeries {
id
title
cancelledAt
recurrenceRule
}
}
`,
Expand Down Expand Up @@ -307,8 +299,15 @@ const MeetingCard = (props: Props) => {
<MeetingImgWrapper>
<MeetingImgBackground meetingType={meetingType} />
<MeetingTypeLabel>{MEETING_TYPE_LABEL[meetingType]}</MeetingTypeLabel>
{meetingType === 'teamPrompt' && isRecurring && (
<RecurringLabel>Recurring</RecurringLabel>
{isRecurring && (
<span
className={clsx(
'absolute right-2 top-2 rounded-[64px] px-2 py-1 text-[11px] font-medium leading-3',
RECURRING_LABEL_COLORS[meetingType]
)}
>
Recurring
</span>
)}
<Link to={meetingLink}>
<MeetingImg src={ILLUSTRATIONS[meetingType]} alt='' />
Expand Down
8 changes: 3 additions & 5 deletions packages/client/components/MeetingsDash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,9 @@ graphql`
lastSeenAtURLs
}
}
... on TeamPromptMeeting {
meetingSeries {
createdAt
cancelledAt
}
meetingSeries {
createdAt
cancelledAt
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions packages/client/mutations/StartRetrospectiveMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,16 @@ graphql`
`

const mutation = graphql`
mutation StartRetrospectiveMutation($teamId: ID!, $gcalInput: CreateGcalEventInput) {
startRetrospective(teamId: $teamId, gcalInput: $gcalInput) {
mutation StartRetrospectiveMutation(
$teamId: ID!
$recurrenceSettings: RecurrenceSettingsInput
$gcalInput: CreateGcalEventInput
) {
startRetrospective(
teamId: $teamId
recurrenceSettings: $recurrenceSettings
gcalInput: $gcalInput
) {
... on ErrorPayload {
error {
message
Expand Down
88 changes: 83 additions & 5 deletions packages/server/__tests__/processRecurrence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {RRule} from 'rrule'
import getRethink from '../database/rethinkDriver'
import MeetingTeamPrompt from '../database/types/MeetingTeamPrompt'
import TeamPromptResponsesPhase from '../database/types/TeamPromptResponsesPhase'
import MeetingRetrospective from '../database/types/MeetingRetrospective'
import ReflectPhase from '../database/types/ReflectPhase'
import generateUID from '../generateUID'
import {insertMeetingSeries as insertMeetingSeriesQuery} from '../postgres/queries/insertMeetingSeries'
import {getUserTeams, sendIntranet, signUp} from './common'
import createNewMeetingPhases from '../graphql/mutations/helpers/createNewMeetingPhases'

const PROCESS_RECURRENCE = `
mutation {
Expand Down Expand Up @@ -180,7 +183,7 @@ test('Should end meetings that are scheduled to end in the past', async () => {
expect(actualMeeting.endedAt).toBeTruthy()
}, 10000)

test('Should end the current meeting and start a new meeting', async () => {
test('Should end the current team prompt meeting and start a new meeting', async () => {
const r = await getRethink()
const {userId} = await signUp()
const {id: teamId} = (await getUserTeams(userId))[0]
Expand All @@ -196,7 +199,7 @@ test('Should end the current meeting and start a new meeting', async () => {
dtstart: startDate
})

const newMeetingSeriesId = await insertMeetingSeriesQuery({
const meetingSeriesId = await insertMeetingSeriesQuery({
meetingType: 'teamPrompt',
title: 'Daily Test Standup',
recurrenceRule: recurrenceRule.toString(),
Expand All @@ -214,7 +217,82 @@ test('Should end the current meeting and start a new meeting', async () => {
facilitatorUserId: userId,
meetingPrompt: 'What are you working on today? Stuck on anything?',
scheduledEndTime: new Date(Date.now() - ms('5m')),
meetingSeriesId: newMeetingSeriesId
meetingSeriesId
})

// The last meeting in the series was created just over 24h ago, so the next one should start
// soon.
meeting.createdAt = new Date(meeting.createdAt.getTime() - ms('25h'))

await r.table('NewMeeting').insert(meeting).run()

const update = await sendIntranet({
query: PROCESS_RECURRENCE,
isPrivate: true
})

expect(update).toEqual({
data: {
processRecurrence: {
meetingsStarted: 1,
meetingsEnded: 1
}
}
})

await assertIdempotency()

const actualMeeting = await r.table('NewMeeting').get(meetingId).run()
expect(actualMeeting.endedAt).toBeTruthy()

const lastMeeting = await r
.table('NewMeeting')
.filter({meetingType: 'teamPrompt', meetingSeriesId})
.orderBy(r.desc('createdAt'))
.nth(0)
.run()

expect(lastMeeting).toMatchObject({
name: expect.stringMatching(/Daily Test Standup.*/),
meetingSeriesId
})
})

test('Should end the current retro meeting and start a new meeting', async () => {
const r = await getRethink()
const {userId} = await signUp()
const {id: teamId} = (await getUserTeams(userId))[0]

const now = new Date()

// Create a meeting series that's been going on for a few days, and happens daily at 9a UTC.
const startDate = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 2, 9)
)
const recurrenceRule = new RRule({
freq: RRule.DAILY,
dtstart: startDate
})

const meetingSeriesId = await insertMeetingSeriesQuery({
meetingType: 'retrospective',
title: 'Daily Retro', //they're really committed to improving
recurrenceRule: recurrenceRule.toString(),
duration: 24 * 60, // 24 hours
teamId,
facilitatorId: userId
})

const meetingId = generateUID()
const meeting = new MeetingRetrospective({
id: meetingId,
teamId,
meetingCount: 0,
phases: [new ReflectPhase(teamId, [])],
facilitatorUserId: userId,
scheduledEndTime: new Date(Date.now() - ms('5m')),
meetingSeriesId,
templateId: 'startStopContinueTemplate'
})

// The last meeting in the series was created just over 24h ago, so the next one should start
Expand Down Expand Up @@ -244,13 +322,13 @@ test('Should end the current meeting and start a new meeting', async () => {

const lastMeeting = await r
.table('NewMeeting')
.filter({meetingType: 'teamPrompt', meetingSeriesId: newMeetingSeriesId})
.filter({meetingType: 'retrospective', meetingSeriesId})
.orderBy(r.desc('createdAt'))
.nth(0)
.run()

expect(lastMeeting).toMatchObject({
name: expect.stringMatching(/Daily Test Standup.*/)
meetingSeriesId
})
})

Expand Down
10 changes: 8 additions & 2 deletions packages/server/database/types/MeetingRetrospective.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface Input {
resetReflectionGroups?: AutogroupReflectionGroupType[]
recallBotId?: string
videoMeetingURL?: string
meetingSeriesId?: number
scheduledEndTime?: Date
}

export function isMeetingRetrospective(meeting: Meeting): meeting is MeetingRetrospective {
Expand Down Expand Up @@ -71,7 +73,9 @@ export default class MeetingRetrospective extends Meeting {
autogroupReflectionGroups,
resetReflectionGroups,
recallBotId,
videoMeetingURL
videoMeetingURL,
meetingSeriesId,
scheduledEndTime
} = input
super({
id,
Expand All @@ -80,7 +84,9 @@ export default class MeetingRetrospective extends Meeting {
phases,
facilitatorUserId,
meetingType: 'retrospective',
name: name ?? `Retro #${meetingCount + 1}`
name: name ?? `Retro #${meetingCount + 1}`,
meetingSeriesId,
scheduledEndTime
})
this.totalVotes = totalVotes
this.maxVotesPerGroup = maxVotesPerGroup
Expand Down
23 changes: 23 additions & 0 deletions packages/server/dataloader/customLoaderMakers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,29 @@ export const activeMeetingsByMeetingSeriesId = (parent: RootDataLoader) => {
)
}

export const lastMeetingByMeetingSeriesId = (parent: RootDataLoader) => {
return new DataLoader<number, AnyMeeting | null, string>(
async (keys) => {
const r = await getRethink()
const res = await Promise.all(
keys.map((key) => {
return r
.table('NewMeeting')
.getAll(key, {index: 'meetingSeriesId'})
.orderBy(r.desc('createdAt'))
.nth(0)
.default(null)
.run()
})
)
return res
},
{
...parent.dataLoaderOptions
}
)
}

export const billingLeadersIdsByOrgId = (parent: RootDataLoader) => {
return new DataLoader<string, string[], string>(
async (keys) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const generateGroups = async (
teamId: string,
dataLoader: DataLoaderWorker
) => {
if (reflections.length === 0) return
const {meetingId} = reflections[0]!
const team = await dataLoader.get('teams').loadNonNull(teamId)
const organization = await dataLoader.get('organizations').load(team.orgId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const safeCreateRetrospective = async (
disableAnonymity: boolean
templateId: string
videoMeetingURL?: string
meetingSeriesId?: number
scheduledEndTime?: Date
},
dataLoader: DataLoaderWorker
) => {
Expand Down

0 comments on commit df2e992

Please sign in to comment.