This tutorial is part of the Building Your Startup With PHP series on Envato Tuts+. In this series, I'm guiding you through launching a startup from concept to reality using my Meeting Planner app as a real-life example. Every step along the way, I'll release the Meeting Planner code as open-source examples you can learn from. I'll also address startup-related business issues as they arise.
Ongoing Development of Group Meetings
Welcome! This is the follow-up episode to Building Your Startup: Meetings with Multiple Participants. Today, I'll be completing the work that we began in that episode: scheduling multiple participant meetings.
A Brief Refresher
Scheduling meetings with multiple participants was always a major goal for Meeting Planner, which launched with only 1:1 scheduling. Multiple participant meetings are the most challenging for people to try to schedule with each other and therefore one of the most valuable features for the Meeting Planner service to provide.
In today's tutorial, I'm going to cover reviewing all the areas of the site affected by multiple participant meetings, handling and smartly displaying lists of recipients of various status, properly managing notifications and notification filtering for groups, and finally upgrading the recently launched request meeting changes feature.
Schedule Your First Group Meeting
Please do schedule your own group meeting today! Invite a few friends to meet you for kombucha or kava. Share your thoughts and feedback of everyone's experience in the comments below. I do participate in the discussions, but you can also reach me @reifman on Twitter. I'm always open to new feature ideas for Meeting Planner as well as suggestions for future series episodes.
As a reminder, all the code for Meeting Planner is provided open source and written in the Yii2 Framework for PHP. If you'd like to learn more about Yii2, check out my parallel series Programming With Yii2.
Reviewing the Code
As you can imagine, transforming Meeting Planner from 1:1 meetings to group meetings touched nearly all of the code. I had to think through all of the areas, review the code, and make small to moderate changes in many places. In other areas, I'd architected for multiple participants, and changes were not needed or were minor.
For example, group scheduling touched:
- Sending invitations and Finalizing (confirming) meeting schedules
- Making Changes, Resending, Repeating, Rescheduling a meeting
- Sending a running late notice
- Meeting Reminders
-
Requesting meeting changes
- Notifications
Mostly, where I'd previously just send to participant[0], the first and only participant, I now needed to process the array of participants. And, in doing so, I needed to check:
- Is this participant an organizer?
- Has this person been removed or declined on their own?
- In the future, has this person opted out of notifications for this meeting?
The Challenges of Testing
With more resources, I could have managed this more comprehensively with automated testing. However, working solo with shipping as a goal, I manually tested everything exhaustively.
I used a catchall domain email so I could invite n1, n2, n3, n4 and n5 @mytestdomain.com to my sample group meetings. Fortunately, the Meeting Planner invitations make it easy to log in quickly with any account by clicking within each meeting invite; this aided my testing.
It was important to review nearly all of the meeting planning code.
But let's get back to the more specific coding challenges of the second half of the group scheduling feature.
Smartly Displaying Participant Lists
A while ago, I built a MiscHelpers
method to display lists grammatically correctly in English with "and" before the last name, as shown in the meeting index below:
However, I wanted to simplify the display of date time and place availability. For example, rather than listing five names of people who accepted meeting at Herkimer Coffee, I updated MiscHelpers::listNames
to say, "everyone else":
public static function listNames($items,$everyoneElse=false,$total_count=0,$anyoneElse=false) { $temp =''; $x=1; $cnt = count($items); if ($everyoneElse && $cnt >= ($total_count-1)) { if (!$anyoneElse) { $temp = Yii::t('frontend','everyone else'); } else { $temp = Yii::t('frontend','anyone else'); } } else { foreach ($items as $i) { $temp.= MiscHelpers::getDisplayName($i); if ($x == ($cnt-1)) { $temp.=' and '; } else if ($x < ($cnt-1)) { $temp.=', '; } $x+=1; } } return $temp; }
You can see this in action below:
But rather than say "No response from everyone else," it's more proper to say "No response from anyone else," which the code does.
Below, you can see MeetingPlace::getWhereStatus()
preparing these strings for each place in the meeting places panel:
public static function getWhereStatus($meeting,$viewer_id) { // get an array of textual status of meeting places for $viewer_id // Acceptable / Rejected / No response: $whereStatus['style'] = []; $whereStatus['text'] = []; foreach ($meeting->meetingPlaces as $mp) { // build status for each place $acceptableChoice=[]; $rejectedChoice=[]; $unknownChoice=[]; // to do - add meeting_id to MeetingPlaceChoice for sortable queries foreach ($mp->meetingPlaceChoices as $mpc) { if ($mpc->user_id == $viewer_id) continue; switch ($mpc->status) { case MeetingPlaceChoice::STATUS_UNKNOWN: $unknownChoice[]=$mpc->user_id; break; case MeetingPlaceChoice::STATUS_YES: $acceptableChoice[]=$mpc->user_id; break; case MeetingPlaceChoice::STATUS_NO: $rejectedChoice[]=$mpc->user_id; break; } } // to do - integrate current setting for this user in style setting $temp =''; // count those still in attendance $cntP = Participant::find() ->where(['meeting_id'=>$meeting->id]) ->andWhere(['status'=>Participant::STATUS_DEFAULT]) ->count()+1; if (count($acceptableChoice)>0) { $temp.='Acceptable to '.MiscHelpers::listNames($acceptableChoice,true,$cntP).'. '; $whereStatus['style'][$mp->place_id]='success'; } if (count($rejectedChoice)>0) { $temp.='Rejected by '.MiscHelpers::listNames($rejectedChoice,true,$cntP).'. '; $whereStatus['style'][$mp->place_id]='danger'; } if (count($unknownChoice)>0) { $temp.='No response from '.MiscHelpers::listNames($unknownChoice,true,$cntP,true).'.'; $whereStatus['style'][$mp->place_id]='warning'; } $whereStatus['text'][$mp->place_id]=$temp; } return $whereStatus; }
Each user has a MeetingPlaceChoice
row related to a MeetingPlace
which records whether a place is acceptable, not acceptable or not responded yet. MeetingTimeChoice
also exists similarly. This information is passed off to listNames()
.
Declining and Withdrawing From a Meeting
Groups also required more intricacy with declining a meeting invitation. Earlier, if one participant declined, the meeting was effectively canceled. Now, one might decline, leaving three others to carry on.
So if a participant receives an invitation to a meeting, they can Decline it. But if the meeting has already been confirmed and finalized, then they are essentially Withdrawing as you can see below:
Note: In the above image, it's Sarah Smithers seeing the Withdraw button; Robert McSmith had been removed by another organizer, either Jeff or Alex.
However, if it's an organizer (meeting owner or anointed participant organizer), they can just Cancel the meeting. Below is from _command_bar_confirmed.php. It determines which buttons to present:
if (!$isPast) { if ($model->isOrganizer()) { echo Html::a('<i class="glyphicon glyphicon-remove-circle"></i> '.Yii::t('frontend', 'Cancel'), ['cancel', 'id' => $model->id], ['class' => 'btn btn-primary btn-danger', 'title'=>Yii::t('frontend','Cancel'), 'data-confirm' => Yii::t('frontend', 'Are you sure you want to cancel this meeting?') ]) ; } else { if ($model->getParticipantStatus(Yii::$app->user->getId())==Participant::STATUS_DEFAULT) { echo Html::a('<i class="glyphicon glyphicon-remove-circle"></i> '.Yii::t('frontend', 'Withdraw'), ['decline', 'id' => $model->id], ['class' => 'btn btn-primary btn-danger', 'title'=>Yii::t('frontend','Withdraw from the meeting'), 'data-confirm' => Yii::t('frontend', 'Are you sure you want to decline attendance to this meeting?') ]) ; } else { // to do - offer rejoin meeting option }
Prioritization is a key element of building a startup. So while I wanted to offer a way for a user who had withdrawn to rejoin a meeting, I decided to add this to the Asana task list for later. A user who rejoins would need updated notifications and possibly some updates to their scheduling data structures.
Thinking Through Notifications
While with 1:1 meetings, every change needed to be sent to the other party, this didn't necessarily make sense for 12-person meetings—or did it? It depends.
Initially, I created general guidelines. If a participant was updating their preferences for a specific date time or place, only the owner and other organizers needed to be updated about this.
I created an array $groupSkip
in MeetingLog
which determined events that should not be sent to other participants:
public static $groupSkip=[ MeetingLog::ACTION_ACCEPT_ALL_PLACES, MeetingLog::ACTION_ACCEPT_PLACE, MeetingLog::ACTION_REJECT_PLACE, MeetingLog::ACTION_ACCEPT_ALL_TIMES, MeetingLog::ACTION_ACCEPT_TIME, MeetingLog::ACTION_REJECT_TIME ];
In MeetingLog::getHistory
, we skip notifying a participant for these events but always notify organizers:
if ( ... // skip over availability response events in multi participant meetings ($isGroup && !$isOrganizer && in_array($e->action,MeetingLog::$groupSkip)) ) { $num_events-=1; // skip event, reduce number of events continue; }
In one unusual example, the code was actually made simpler with multiple participants: Meeting::findFresh()
, which looks for updates of meeting changes to share via email.
Earlier, we had to identify which of two users performed the actions and whether or not to notify either. Now, we just notify the owner and then notify the participants:
if ((time()-$m->logged_at) > MeetingLog::TIMELAPSE && $m->status>=Meeting::STATUS_SENT) { // // get logged items which occured after last cleared_at $m->notify($m->id,$m->owner_id); // notify the participants foreach ($m->participants as $p) { // don't update removed and declined participants if ($p->status!=Participant::STATUS_DEFAULT) { continue; } //echo 'Notify P-id: '.$p->participant_id.'<br />'; $m->notify($m->id,$p->participant_id); }
Any filtering is done deeper within the event log textualization.
Enhanced Notifications: "Everyone Is Available!"
I also created a new notification for alerting organizers when everyone is agreeable to at least one specific place and time, MeetingLog::ACTION_SEND_EVERYONE_AVAILABLE
:
// check if meeting has place and time for everyone now if (count($m->participants)>1 && !MeetingLog::hasEventOccurred($m->id,MeetingLog::ACTION_SEND_EVERYONE_AVAILABLE) && Meeting::isEveryoneAvailable($m->id)) { Meeting::notifyOrganizers($m->id,MeetingLog::ACTION_SEND_EVERYONE_AVAILABLE); MeetingLog::add($m->id,MeetingLog::ACTION_SEND_EVERYONE_AVAILABLE,0); }
This notifies organizers when the meeting is ready to finalize/confirm.
Here's the code that looks at all the meeting places and meeting times to see if everyone is agreeable to at least one place:
public static function isEveryoneAvailable($meeting_id) { // check that one place works for everyone attending $m = Meeting::findOne($meeting_id); $cntAll = $m->countAttendingParticipants(true); // count organizer + attending participants $mpExists=false; $mtExists=true; $mps = \frontend\models\MeetingPlace::find()->where(['meeting_id'=>$meeting_id])->all(); foreach ($mps as $mp) { $cnt=0; foreach ($mp->meetingPlaceChoices as $mpc) { if ($m->getParticipantStatus($mpc->user_id)!=Participant::STATUS_DEFAULT) { // skip withdrawn, declined, removed participants continue; } if ($mpc->status == \frontend\models\MeetingPlaceChoice::STATUS_YES) { $cnt+=1; } } if ($cnt >=$cntAll) { $mpExists = true; } } $mts = \frontend\models\MeetingTime::find()->where(['meeting_id'=>$meeting_id])->all(); foreach ($mts as $mt) { $cnt=0; foreach ($mt->meetingTimeChoices as $mtc) { if ($m->getParticipantStatus($mtc->user_id)!=Participant::STATUS_DEFAULT) { // skip withdrawn, declined, removed participants continue; } if ($mtc->status == \frontend\models\MeetingTimeChoice::STATUS_YES) { $cnt+=1; } } if ($cnt >=$cntAll) { $mtExists = true; } } // at least one time and one place works for everyone attending if ($mpExists && $mtExists) { return true; } else { return false; } }
Similarly, I built a function to flash a notice to the organizer that none of the date times and places are acceptable to anyone, Meeting::isSomeoneAvailable()
:
if ($model->status <= Meeting::STATUS_SENT) { if ($model->isOrganizer() && ($model->status == Meeting::STATUS_SENT) && !$model->isSomeoneAvailable()) { Yii::$app->getSession()->setFlash('danger', Yii::t('frontend', 'None of the participants are available for the meeting\'s current options.')); }
This indicates that they should suggest additional date times and/or places.
Updating Meeting Reminders
Everything about meeting reminders worked well for multiple participants, but I did need to turn off reminders if a participant had declined or withdrawn from a meeting or been removed:
$cnt =1; foreach ($mtg->participants as $p) { if ($p->status ==Participant::STATUS_DEFAULT) { $attendees[$cnt]=$p->participant_id; $cnt+=1; }
STATUS_DEFAULT
indicates an attendee that should be added to the array of users to email reminders too.
Reviewing Calendar Files
I also reviewed the work of generating calendar files for invitations to ensure that all the attendees are included. In Meeting::prepareDownloadIcs()
, I put together an attendees array with the owner and participants actively attending:
$attendees = array(); foreach ($m->participants as $p) { if ($p->status ==Participant::STATUS_DEFAULT) { $auth_key=\common\models\User::find()->where(['id'=>$p->participant_id])->one()->auth_key; $attendees[$cnt]=['user_id'=>$p->participant_id,'auth_key'=>$auth_key, 'email'=>$p->participant->email, 'username'=>$p->participant->username]; $cnt+=1; // reciprocate friendship to organizer \frontend\models\Friend::add($p->participant_id,$p->invited_by); // to do - reciprocate friendship in multi participant meetings } } $auth_key=\common\models\User::find()->where(['id'=>$m->owner_id])->one()->auth_key; $attendees[$cnt]=['user_id'=>$m->owner_id, 'auth_key'=>$auth_key, 'email'=>$m->owner->email, 'username'=>$m->owner->username]; foreach ($attendees as $cnt=>$a) { if ($a['user_id']==$actor_id) { $icsPath = Meeting::buildCalendar($m->id,$chosenPlace,$chosenTime,$a,$attendees);
During this time, I also learned how to indicate that a calendar file of a canceled meeting should trigger removal of the event from someone's calendar. The ics standard is powerful though not easily learned.
Updating Request Changes for Groups
As I wrote about recently, the Requesting Meeting Changes feature required a lot of work and new UX.
For multiple participant meetings, the social engineering needed to change again. For example, organizers can accept or reject requests and change the meeting schedule. However, participants can only express Like, Dislike or Don't Care about change requests. And organizers should see all the participants' responses to aid them in their decision-making.
Here's what the participant sees after they submit their change request:
New change requests needed to be sent to all participants. This is handled transparently by the activity log notifications. When a request is made, this event is created in RequestController::actionCreate()
submit:
MeetingLog::add($model->meeting_id,MeetingLog::ACTION_REQUEST_CREATE,Yii::$app->user->getId(),$model->id);
Here's what the requested change notification looks like to other participants:
Everyone is asked to respond. Clicking Respond to Request jumps right to the request. Or you can find it through the list of requests from the flash alert link on the meeting shown above.
New UX for Participants Responding to Requests
Here's the form that participants see when they respond to a request:
If there are other participant responses already, they'll see them:
Here's the top part of that form in /frontend/views/request-response/_form.php:
<p><em> <?= $subject ?> </em> </p> <?= GridView::widget([ 'dataProvider' => $responseProvider, 'columns' => [ [ 'label'=>'Responses from Other Participants', 'attribute' => 'responder_id', 'format' => 'raw', 'value' => function ($model) { $note=''; if (!empty($model->note)) { $note = ' said, "'.$model->note.'"'; } return '<div>'.MiscHelpers::getDisplayName($model->responder_id).' '.$model->lookupOpinion().$note.'</div>'; }, ], ], ]); ?> <div class="request-response-form"> <?php $form = ActiveForm::begin(); ?> <?= BaseHtml::activeHiddenInput($model, 'responder_id'); ?> <?= BaseHtml::activeHiddenInput($model, 'request_id'); ?> <?= $form->field($model, 'note')->label(Yii::t('frontend','Include a note'))->textarea(['rows' => 6])->hint(Yii::t('frontend','optional')) ?>
The Gridview lists the existing responses, e.g. Like, Dislike, Neutral, and any personal note attached.
Then, here's the code for the lower half of the form, which will display Like, Dislike, Don't Care to participants but Accept and Make Changes or Decline Request to owners.
<?php if (!$isOwner && $isOrganizer) { ?> <p><em><?= Yii::t('frontend','Since you are an organizer, you can accept the request and make the changes or reject it.');?></em></p> <?php } ?> <?php if ($isOrganizer) { ?> <div class="form-group"> <?= Html::submitButton(Yii::t('frontend', 'Accept and Make Changes'), ['class' => 'btn btn-success','name'=>'accept',]) ?> <?= Html::submitButton(Yii::t('frontend', 'Decline Request'),['class' => 'btn btn-danger','name'=>'reject', 'data' => [ 'confirm' => Yii::t('frontend', 'Are you sure you want to decline this request?'), 'method' => 'post', ],]) ?> </div> <?php } ?> <?php if (!$isOwner && $isOrganizer) { ?> <p><em><?= Yii::t('frontend','Or, you can just express your opinion and defer to other organizers.');?></em></p> <?php } ?> <?php if (!$isOwner) { ?> <?php if (!$isOrganizer) { ?> <p><em><?= Yii::t('frontend','Please share your opinion of this request for the organizers to consider.');?></em></p> <?php } ?> <div class="form-group"> <?= Html::submitButton(Yii::t('frontend', 'Like'), ['class' => 'btn btn-success','name'=>'like',]) ?> <?= Html::submitButton(Yii::t('frontend', 'Don\'t Care'), ['class' => 'btn btn-info','name'=>'neutral',]) ?> <?= Html::submitButton(Yii::t('frontend', 'Dislike'),['class' => 'btn btn-danger','name'=>'dislike',]) ?> </div> <?php } ?> <?php ActiveForm::end(); ?>
Participants that are anointed organizers are shown both sets of buttons and can either express their opinion or make or decline the change.
Here's the email notification that a change has been accepted:
Of course, an updated invitation and calendar files will be sent out to everyone if a change is made.
Always More Improvements to Make
I hope you've enjoyed these two episodes (today's and Building Your Startup: Meetings With Multiple Participants). In startup mode with a huge new feature, there's always a focused, streamlined launch effort, which leaves many loose ends undone and defects unpolished.
A few examples of this include:
- Rejoining meetings you've declined or withdrawn from.
- Improved presentation of the participant list in invitations.
- Options to keep the participant list and/or their individual statuses private from other participants.
- Improved handling and presentation of group contact information and virtual conference details, e.g. a conference line and participation code.
- Secure URL for sharing meeting invitations. This would allow organizers to share a URL on Facebook or via email to invite new participants.
Despite these shortcomings, I've worked super hard to reach this level of function and usability for Meeting Planner. I'm super excited about its progress and hearing good feedback about it from friends and colleagues.
I'm turning in these two tutorials today and taking a few days offline in the forest for some downtime. Rest is important. Being in touch with nature helps remind you of what's important in life—startups aren't always, actually. They may be creative pursuits, important to our income and careers, they may in some cases help people live more efficiently and productively—but they are often distant from the earth, from friendships, and from helping others less fortunate. These are all things I think about every day and will do again while I'm away.
I'll ask myself repeatedly am I doing everything I want to be doing with my time—especially in light of my brain surgery.
I'll also take heart from the fact that I'm proud of Meeting Planner, especially the work to date and its growing usefulness. Overall, the service's beta is nearing completion.
Multiple participant meetings had been the most daunting, complex remaining work item. Looking ahead, features and tasks are more moderate, smaller, and more easily manageable. I'm excited about its future prospects!
If you haven't yet, go schedule your first meeting with Meeting Planner now!
A tutorial on crowdfunding is also in the works, so please follow our WeFunder Meeting Planner page.
You can also reach out to me @reifman. I'm always open to new feature ideas and topic suggestions for future tutorials.
Stay tuned for all of this and more upcoming tutorials by checking out the Building Your Startup With PHP series.
Comments