Skip to content

Instantly share code, notes, and snippets.

@fabmiz
Forked from cdeeter/application.controller\.js
Last active July 1, 2021 06:15
Show Gist options
  • Save fabmiz/6c7d22ec7267872003a07747474b506d to your computer and use it in GitHub Desktop.
Save fabmiz/6c7d22ec7267872003a07747474b506d to your computer and use it in GitHub Desktop.
Coding Challenge
import Controller from '@ember/controller';
import { reads, uniqBy, mapBy } from '@ember/object/computed';
export default Controller.extend({
timeslots: reads('model'),
timeslotsWithUniqueDates: uniqBy('timeslots', 'isoDate'),
isoDates: mapBy('timeslotsWithUniqueDates', 'isoDate'),
});
import Route from '@ember/routing/route';
import Timeslot, { maybeUnwrapProxy } from 'twiddle/utils';
export default Route.extend({
async model() {
const timeslots = await this.store.findAll('timeslot');
return maybeUnwrapProxy(timeslots).map(timeslot => new Timeslot(timeslot));
},
afterModel(model) {
const uniqDates = Array.from(new Set(model.map(timeslot => timeslot.isoDate)));
return this.replaceWith('date', `${uniqDates[0]}`);
},
});
<h1>Peek Calendar</h1>
<br>
<div role='navigation' class='calendar'>
{{#each this.isoDates as |date|}}
<div data-test-links class='calendar-day'>
{{#link-to 'date' date}}
{{date}}
{{/link-to}}
</div>
{{/each}}
</div>
<br>
<br>
<main>
{{outlet}}
</main>
import Controller from '@ember/controller';
export default Controller.extend({
});
import Route from '@ember/routing/route';
export default class DateRoute extends Route {
model({ iso_date: date }) {
const timeslots = this.modelFor('application');
return timeslots.filter(timeslot => timeslot.isoDate === date);
}
};
<div role='presentation' class='calendar-day-detail'>
{{#let @model as |timeslots|}}
<DayWrapper @timeslots={{timeslots}} as |day|>
{{#each timeslots as |timeslot|}}
<day.timeslotCard @timeslot={{timeslot}} />
{{/each}}
</DayWrapper>
{{/let}}
</div>
<div data-test-day-axis class='day-axis'>
{{#each @timeAxisTickLabels as |tickLabel|}}
<time class='day-axis-tick'>{{tickLabel}}</time>
{{/each}}
</div>
import Component from '@glimmer/component';
export default class DayWrapper extends Component {
interval = 30 * 60 * 1000; // 30 min in ms
get timeAxisLength() {
return Date.parse(this.latestEndTime) - Date.parse(this.earliestStartTime);
}
get timeAxisTickCount() {
return this.timeAxisLength && Math.floor(this.timeAxisLength / this.interval);
}
get earliestStartTime() {
return this.args.timeslots[0].startDateTime;
}
get latestEndTime() {
const lastIndex = this.args.timeslots.length - 1;
return this.args.timeslots[lastIndex].endDateTime;
}
get timeAxisTickLabels() {
const tickLabels = [];
let acc = Date.parse(this.earliestStartTime);
for (let count=0; count <= this.timeAxisTickCount; count++) {
const minutes = (new Date(acc)).getMinutes();
const hours = (new Date(acc)).getHours();
const paddedMinutes = minutes ? `:${minutes}` : ':00';
const axisTickLabel = `${hours}${paddedMinutes}`;
tickLabels.push(axisTickLabel);
acc += this.interval;
}
return tickLabels;
}
}
<DayAxis @timeAxisTickLabels={{this.timeAxisTickLabels}} />
<div role='presentation' ...attributes>
{{yield (hash
timeslotCard=(component 'timeslot-card'
maxTimeslotDuration=this.timeAxisLength
earliestStartTime=this.earliestStartTime
)
)}}
</div>
import { Response } from 'ember-cli-mirage';
export default function() {
window.server = this;
this.get('/timeslots', function (schema, request) {
const { date } = request.queryParams;
if (!date) {
return schema.timeslots.all();
}
return schema.timeslots.where({ date: date });
});
};
import { Model } from 'ember-cli-mirage'
export default Model.extend({
})
export default function(server) {
// Day 1 timeslots
server.create('timeslot',{
date: '2021-06-04',
startTime: '10:00',
endTime: '11:00',
activityName: 'Activity 1',
availableSpots: 10,
bookedCount: 0,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-04',
startTime: '11:00',
endTime: '12:00',
activityName: 'Activity 1',
availableSpots: 7,
bookedCount: 3,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-04',
startTime: '13:00',
endTime: '14:00',
activityName: 'Activity 2',
availableSpots: 14,
bookedCount: 1,
maxGuests: 15
});
// Day 2 timeslots
server.create('timeslot',{
date: '2021-06-05',
startTime: '10:00',
endTime: '11:00',
activityName: 'Activity 1',
availableSpots: 9,
bookedCount: 1,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-05',
startTime: '10:00',
endTime: '12:00',
activityName: 'Activity 2',
availableSpots: 0,
bookedCount: 15,
maxGuests: 15
});
server.create('timeslot',{
date: '2021-06-05',
startTime: '11:30',
endTime: '13:00',
activityName: 'Activity 2',
availableSpots: 10,
bookedCount: 5,
maxGuests: 15
});
server.create('timeslot',{
date: '2021-06-05',
startTime: '12:30',
endTime: '14:00',
activityName: 'Activity 1',
availableSpots: 0,
bookedCount: 10,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-05',
startTime: '15:00',
endTime: '16:30',
activityName: 'Activity 1',
availableSpots: 8,
bookedCount: 2,
maxGuests: 10
});
// Day 3 timeslots
server.create('timeslot',{
date: '2021-06-06',
startTime: '09:00',
endTime: '12:00',
activityName: 'Activity 1',
availableSpots: 0,
bookedCount: 10,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-06',
startTime: '10:00',
endTime: '14:00',
activityName: 'Activity 3',
availableSpots: 5,
bookedCount: 0,
maxGuests: 5
});
server.create('timeslot',{
date: '2021-06-06',
startTime: '11:00',
endTime: '12:00',
activityName: 'Activity 2',
availableSpots: 13,
bookedCount: 2,
maxGuests: 15
});
server.create('timeslot',{
date: '2021-06-06',
startTime: '13:00',
endTime: '16:30',
activityName: 'Activity 2',
availableSpots: 15,
bookedCount: 0,
maxGuests: 15
});
server.create('timeslot', {
date: '2021-06-06',
startTime: '13:00',
endTime: '16:00',
activityName: 'Activity 1',
availableSpots: 4,
bookedCount: 6,
maxGuests: 10
});
server.create('timeslot',{
date: '2021-06-06',
startTime: '16:30',
endTime: '18:00',
activityName: 'Activity 3',
availableSpots: 3,
bookedCount: 2,
maxGuests: 5
});
}
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
export default class extends Model {
// date the activity timeslot takes place
@attr('date') date;
// hh:mm formatted timeslot start time
@attr('string') startTime;
// hh:mm formatted timeslot end time
@attr('string') endTime;
// name of the activity the timeslot is for
@attr('string') activityName;
// number of spots booked for the activity timeslot
@attr('number') bookedCount;
// number of spots available for the timeslot
@attr('number') availableSpots;
// max number of guests allowed on this timeslot
@attr('number') maxGuests;
}
import EmberRouter from '@ember/routing/router';
import config from './config/environment';
const Router = EmberRouter.extend({
location: 'none',
rootURL: config.rootURL
});
Router.map(function() {
this.route('date', { path: '/date/:iso_date' });
});
export default Router;
body {
margin: 12px 16px;
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 12pt;
}
.calendar {
display: flex;
flex-direction: row;
}
.calendar-day {
margin-right: 10px;
}
.calendar-day .active {
background-color: #E8F0F2;
padding: 10px;
}
.calendar-day-detail {
background-color: #A2DBFA;
padding: 10px;
}
.timeslot-card-summary {
font-size: .95rem;
}
.timeslot-card {
margin-bottom: 10px;
padding: 5px;
box-shadow: 0 0 15px rgb(0 0 0 / 10%);
background-color: #fff200;
opacity: .65;
}
.timeslot-card.available {
background-color: #fff200;
opacity: 1;
}
.day-axis {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.day-axis-tick {
font-size: .85rem;
}
.progress {
height: .50rem;
border-radius: 0;
background-color: transparent;
}
import { module, test } from 'qunit';
import { visit, currentURL, findAll, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
module('Acceptance | date detail view', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
test('visiting /date', async function(assert) {
await visit('/');
assert.equal(currentURL(), '/date/2021-06-04', 'at the correct route; index gets redirected');
assert.dom('[data-test-day-axis]').isVisible('time axis shows');
assert.dom('[data-test-timeslot-activity-name]').exists({ count: 3 }, 'shows 3 activity names');
assert.dom('[data-test-timeslot-card]').exists({ count: 3 }, 'has 3 timeslot cards');
assert.dom('[data-test-links]').exists({ count: 3}, 'has 3 links');
const cards = findAll('[data-test-timeslot-card]');
await click(cards[0]);
assert.dom(cards[0]).isFocused('timeslot card is focusable');
assert.equal(cards[0].getAttribute('aria-label'), 'Show timeslot detail', 'timeslot card has aria label');
assert.dom('[data-test-timeslot-detail-popover]').exists('Timeslot detail popover shows on focus');
});
});
import Application from '../app';
import config from '../config/environment';
import { setApplication } from '@ember/test-helpers';
import { assign } from '@ember/polyfills';
import { start } from 'ember-qunit';
let attributes = {
rootElement: '#ember-testing',
autoboot: false
};
attributes = assign(attributes, config.APP);
let application = Application.create(attributes);
setApplication(application);
start();
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { guidFor } from '@ember/object/internals';
export default class TimeslotCard extends Component {
@tracked showTimeSlotDetails = false;
get elementId() {
return guidFor(this);
}
get timeslotIsAvailable() {
return this.args.timeslot.isAvailable;
}
get offset() {
return (Date.parse(this.args.timeslot.startDateTime) - Date.parse(this.args.earliestStartTime)) * 100 / this.args.maxTimeslotDuration;
}
get durationUnit() {
return this.args.timeslot.durationInHrs === 1 ? 'hour' : 'hours';
}
@action
setStyle(element) {
const pctWidth = (this.args.timeslot.duration / this.args.maxTimeslotDuration) * 100;
element.style.width = `${pctWidth}%`;
element.style.marginLeft = `${this.offset}%`;
}
}
<div class='timeslot-card {{if this.timeslotIsAvailable 'available'}}'
tabindex='0'
role='button'
aria-label='Show timeslot detail'
id={{this.elementId}}
data-test-timeslot-card
{{did-insert this.setStyle @maxTimeslotDuration}}
...attributes
>
<div data-test-timeslot-activity-name class='timeslot-card-summary'>
{{@timeslot.activityName}}
</div>
<BsProgress as |pg|>
<pg.bar @value={{@timeslot.bookedPct}} />
</BsProgress>
</div>
<BsPopover
@triggerEvents={{array 'hover' 'focus'}}
@triggerElement='#{{this.elementId}}'
aria-describedBy='#{{this.elementId}}'
data-test-timeslot-detail-popover
>
<div>Duration:
<time datetime='{{@timeslot.durationInHrs}} {{this.durationUnit}}'>
{{@timeslot.durationInHrs}} {{this.durationUnit}}
</time>
</div>
<div>Available: {{@timeslot.availableSpots}}</div>
<div>Booked: {{@timeslot.bookedCount}}</div>
</BsPopover>
{
"version": "0.10.7",
"ENV": {
"modulePrefix": "twiddle",
"ember-cli-mirage": {
"enabled": true
}
},
"EmberENV": {
"FEATURES": {},
"_TEMPLATE_ONLY_GLIMMER_COMPONENTS": false,
"_APPLICATION_TEMPLATE_WRAPPER": true,
"_JQUERY_INTEGRATION": true
},
"options": {
"use_pods": true,
"enable-testing": false
},
"dependencies": {
"jquery": "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.js",
"ember": "3.18.1",
"ember-template-compiler": "3.18.1",
"ember-testing": "3.18.1"
},
"addons": {
"@glimmer/component": "1.0.0",
"ember-data": "3.18.0",
"ember-cli-mirage": "1.1.8",
"ember-bootstrap": "3.1.4",
"@ember/render-modifiers": "1.0.2"
}
}
import { get } from '@ember/object';
import { assert } from '@ember/debug';
export default class Timeslot {
date;
endTime;
startTime;
availableSpots;
activityName;
bookedCount;
maxGuests;
constructor (timeslot) {
this.date = timeslot.getAttributeValue('date');
this.availableSpots = timeslot.getAttributeValue('availableSpots');
this.endTime = timeslot.getAttributeValue('endTime');
this.startTime = timeslot.getAttributeValue('startTime');
this.activityName = timeslot.getAttributeValue('activityName');
this.bookedCount = timeslot.getAttributeValue('bookedCount');
this.maxGuests = timeslot.getAttributeValue('maxGuests');
}
get endDateTime() {
return this.concatTimeToDate(this.endTime);
}
get startDateTime() {
return this.concatTimeToDate(this.startTime);
}
get duration() {
return Date.parse(this.endDateTime) - Date.parse(this.startDateTime);
}
get isoDate() {
return (this.date.toISOString()).split('T')[0];
}
get durationInHrs() {
const oneHourInMs = 60 * 60 * 1000;
return this.duration / oneHourInMs;
}
get isAvailable() {
return Boolean(this.availableSpots);
}
get bookedPct() {
return (this.bookedCount * 100) / this.maxGuests;
}
concatTimeToDate(time) {
assert('time is a required argument!', Boolean(time));
const [hours, minutes] = time.split(':');
const date = new Date(this.date);
date.setHours(hours);
date.setMinutes(minutes);
return date;
}
}
export function maybeUnwrapProxy(proxy) {
if (!proxy) {
return undefined;
}
if ('content' in proxy) {
return get(proxy, 'content');
}
return maybeUnwrapProxy(proxy);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment