Ти пам'ятаєш чудное мгновенье?

[Минулому Році літератури присвячується]
Це була чергова п'ятниця в тихому, затишному барі з найкращими друзями… Розмова йшла як завжди: новини, робота, жарти і знову по колу. В пошуках теми для розмови, потягуючи з пивних кухлів, чомусь згадали про вірші :) І тут кожен став пригадувати, що він ще пам'ятає з тих далеких шкільних років. Якщо спотикався, інші підказували, коли хтось пам'ятав, було досить весело і цікаво. Повертаючись додому в той вечір, я подумав: а що якщо зробити простий веб-додаток, щоб кожен міг згадати ці прекрасні твори російської поетичної думки? Дизайн програми вже крутився в голові, і я засів за розробку...


Постановка завдання

Необхідно написати веб-додаток, де користувачеві випадковим чином випадає вірш, який він повинен закінчити, дописуючи "пропущені" слова у міру розповіді. В кінці користувачеві повинен бути відображений результат і можливість відкрити інше вірш.
Додаткові вимоги до програми:
  • простота і зрозумілість
  • чуйний дизайн та доступність на мобільних пристроях
  • плавна анімація і якість подачі і роботи програми
  • відображення підказок користувачеві, якщо він не пам'ятає пропущеного слова
  • підтримка багатомовності і різного набору віршів/авторів для різних країн
  • зробити повністю статичний додаток, щоб можна було розмістити на GitHub Pages і не платити за хостинг :P

Дизайн

Для оформлення спеціально були проштудированы старі видання віршів, щоб почерпнути стилістику оформлення друкованих видань того часу. Хотілося чогось друкованого, відчуття "папери" і при цьому простого. Нинішнє захоплення Flat-дизайном робить речі простіше, тим більше для програміста, адже він не дизайнер. Імхо, вийшло непогано:


Хорошому проекту потрібно добре ім'я. Перевіривши кілька доменів, я швидко знайшов підходяще ім'я для проекту — literator.io. Це тільки потім я дізнався, скільки коштує домен в зоні .io, але міняти що-то вже було пізно, "мистецтво вимагає жертв".

Розробка

Що може радувати програміста в роботі більше, ніж можливість писати хороший код і не бути пов'язаним дедлайнами?
Але порядок все одно потрібен, і я замутив невеликий Kanban у Trello. Люблю порядок.

Так, помітив, що і на GitHub'е тепер можна створювати борди. Можливо, переїду туди.
Технології
Вибір фреймворку для побудови програми припав на AngularJS, т. к. кілька проектів з основного місця роботи використовували його. Справу було рік тому, Angular 2 тоді був ще в глибокій жопі бете, а React був мені незнайомий. Так само хотілося відточити свої навички в AngularJS, щоб не втратити вправність, т. к. по роботі доводилося писати на ванільному JS під Titanium SDK, а це зовсім інша сфера.
Дані і як їх зберігати
Подумавши над можливою архітектурою і прикинувши різні варіанти, я прийшов до наступної структури:


Як бачите, виділяються дві сутності: автори і вірші. Кожна з сутностей описується метаданими, файли яких розташовуються в тих же директоріях. Вірші (verses) додатково мають файл content.txt, де як раз і зберігається вірш.
Окремо виділяється файл structure.json. Якщо пам'ятаєте, одна з умов завдання було зробити додаток, для якого не потрібен буде бекенд. А раз немає бек-ендом, то ми не можемо перебирати доступні директорії, щоб дізнатися структуру наших даних. Якраз для цього і потрібен файл structure.json, який зберігає всю структуру та метадані. Щоб кожен раз не змінити цей файл вручну, була написана Node-утиліта, яка проходить по всім доступним директорій і збирає метадані (не дарма ж ми їх там розклали, до того ж це зручно).
Як кажуть: "Розділяй і володарюй". Хороший розробник не буде зберігати дані (хоч і статичні) разом з вихідним кодом в тому ж репозиторії, тому для віршів був створений окремий репозиторій. Так само це дозволить використовувати всю міць pull-реквестов для того, щоб бажаючі могли додати нові вірші і нових авторів. Цей репозиторій підключається до основного сховища через Git Submodules, що дає додатковий контроль за тим, яка ревізія даних зараз використовується.
Залишалося змінити таск
grunt build
, щоб все збиралося і копіювалося по своїх місцях.
Вірші і завершення слів
Окремо хотів би зупинитися на тому, як було реалізовано завершення слів і взагалі поділ вірша на фрагменти, які потрібно заповнити користувачеві. Стояв вибір між алгоритмічним вибором блоків і явним зазначенням цих блоків в самому вірші. Ще хотілося зробити можливість вибору складності в майбутньому, тому блоки повинні були обиратися по-різному. Я вибрав друге — вказівка цих блоків в самому вірші. Це вимагає передпідготовки тексту вірша, але дозволяє отримати кращий результат, т. до. дозволяє підібрати [суб'єктивно] найбільш вдалі фрагменти, які будуть відповідати римі/течією вірші і змістом оповідання. Так само той, хто готує вірш, може заздалегідь оцінити на скільки складно/просто буде вирішити конкретний фрагмент.
Йшли {роки{}}. Бур порив {бунтівний} 
Розсіяв колишні {мрії},
І я забув твій {голос} ніжний, 
Твої небесні {риси}.

Як бачите, фрагменти укладені у фігурні дужки. Вкладеність необхідна для вказівки фрагментів різної складності. Дужки "розкриваються" від внутрішніх до зовнішніх, таким чином "легкому" рівнем складності відповідають самі внутрішні дужки в конкретному виділеному фрагменті. Фрагмент виду
{роки{}}
відповідає тому, що він буде використаний тільки для "середньої" складності та пропущений на "легкої", т. к. фігурні дужки нічого не містять. Таким чином можна додати будь-яку складність, в принципі, але на поточний момент в розмітці використовуються тільки дві, а в додатку поки доступна тільки "легка".
При виборі вірші, відбувається його нормалізація для обраної складності і зайві дужки віддаляються, після чого воно розбивається на фрагменти. Якщо цікаво, код нижче. Намагався зробити нормалізацію регуляркой, але так і не зміг зібрати робочу, хоча, думаю це можливо (має працювати для будь-якої вкладеності, не тільки для двох).
Verse.prototype = {
// ...
/**
* Returns string, which normalized passed to difficulty (removes other difficulties' markup)
* @param {String} string
* @param {String} difficulty
* @returns {String}
*/
normalizeStringToDifficulty: function(string, difficulty) {
var self = this;

// Convert difficulty string into int of complexity
var complexity = difficulty === self.DIFFICULTY_EASY ? 1 : 2;

// Normalize verse content to passed difficulty by removing block separators for other difficulties
// (if somebody know easier solution, drop pull me request :)
var contentArray = string.split(");
var startCharPositions = [];
var endCharPositions = [];
contentArray.forEach(function(char, index){
// Count separators
switch (char) {
case self.BLOCK_SEPARATOR_START:
startCharPositions.push(index);
break;

case self.BLOCK_SEPARATOR_END:
endCharPositions.push(index);
break;
}

// Check if we counted all separators for one block (Note: current algo is not working with mixed groups)
if (startCharPositions.length && startCharPositions.length === endCharPositions.length) {
var blockComplexity = Math.min(startCharPositions.length, complexity); // if block has lower complexity, use its maximum

// Cleanup unnecessary blocks' separators
startCharPositions.reverse().forEach(cleanup);
endCharPositions.forEach(cleanup);

// Reset stored positions
startCharPositions = [];
endCharPositions = [];
}
});

return contentArray.join(");

function cleanup(position, index) {
if (index + 1 !== blockComplexity) {
delete contentArray[position];
}
};
}

// ...

/**
* Returns content divided into pieces to display it later
* @param options
* @returns {Array}
*/
getPieces: function(options) {
var self = this;

options = angular.extend({
difficulty: 'easy',
}, options);

// Get normalized content
var contentArray = self.normalizeStringToDifficulty(self.content, options.difficulty).split(");

// Divide into pieces
var pieces = [];
var isInBlock = false;
var blockPiece = null;
contentArray.forEach(function(char){
switch (char) {
case self.BLOCK_SEPARATOR_START:
isInBlock = true;
blockPiece = ";
break;

case self.BLOCK_SEPARATOR_END:
isInBlock = false;
if (blockPiece.length) {
pieces.push(new VerseBlock(blockPiece));
}
break;

default:
if (isInBlock) {
blockPiece += char;
} else {
pieces.push(char);
}
}
});

return pieces;
}

Тестування

Окрему увагу було приділено юзабіліті і сумісності з мобільними платформами, адже як ви знаєте, користувальницький досвід там істотно відрізняється від десктопного. До того ж, мобільний веб-серфінг вже обігнав десктопний.
Safari, що ти робиш...
Певну біль принесла оптимізація під iOS Safari, в основному з-за того, що програмно не можна поставити фокус до поля введення, якщо користувач не вчинив жодних touch-дій. Тому там довелося додати спеціальну підказку, щоб користувач тапнул в будь-яке місце на екрані — тільки тоді ми можемо встановити фокус на потрібному елементі, що трохи псує юзабіліті. Якщо хтось знає, як вирішити цю проблему — пишіть! Моя остання спроба тут https://jsfiddle.net/6tfrh7qn/5/ (відкривайте в iPhone Simulator). Ще не знайшов як отстайлить каретку.


І все одно там якийсь баг з курсором, який може не відображатися після фокуса на полі.
User Testing
У процесі розробки регулярно проводився User Testing (в основному на рідних і близьких) щоб виявити помилки у UI, UX (власний досвід) і взагалі перевірити коректність подання ідеї. Тести дали дуже добрі плоди, дозволивши істотно поліпшити UX.
На одному з тестів з'ясувалося, що користувач думав, що в місці зупинки оповідання потрібно писати все, що пам'ятаєш далі. Тоді перша підказка була змінена з "Почніть друкувати і закінчите вірш" на "Почніть друкувати наступне слово".
Також з'ясувалося, що користувач не розумів, чи правильно він друкує і скільки потрібно друкувати, тому що в основному дивиться на клавіатуру. Тоді я додав звук, який програється при правильному завершення фрагмента. Вийшло досить інтуїтивно.
загалом, дуже корисна практика, не нехтуйте юзер-тестами!
Unit/E2E testing
Також весь код покритий Unit-тестами і E2E-тестами, не в кожному проекті вдається викроїти на це час. Писати їх було одне задоволення, місцями розробка йшла TDD. Так, E2E тести на Protractor можуть сфейлиться, якщо вікно браузера, який запускається тестом, на задньому плані або невидимо. Якщо хто знає, як це виправити, прохання повідомити.

Підбиваючи підсумки

Веб-додаток: http://literator.io
Репозиторій додатки: https://github.com/bobrosoft/literator.io
Репозиторій віршів: https://github.com/bobrosoft/literator.io-verses
Розробка йшла неспішно, і минуло вже більше року з моменту її старту. Хоч і основна частина була завершена досить швидко, потрібен час, щоб все відполірувати і закінчити — принцип Парето в дії :) Іноді пропадало бажання продовжувати, т. к. в голову приходили зовсім нові ідеї, але я зробив зусилля, а то цей гештальт не давав би спокою.
Думаю, що для початку додав всі вірші, які повинні бути відомі більшості з нас. Намагався поки не брати довгі вірші (хоча є "Бородіно"), щоб не втомлювати користувача. Якщо незаслужено пропустив щось, напишіть в коментарях, додам.
Ідеї розвитку проекту (у порядку важливості):
  • додати кнопку "Я не пам'ятаю", щоб пропустити незнайомий вірш
  • змінити відображення результату, зробити його цікавішим, показувати на скільки добре пам'ятаєш конкретне вірш / на скільки хороша пам'ять (побажання з одного з юзер-тестів)
  • багатомовність і різний набір віршів/авторів для різних країн (ще до кінця не реалізовано, але заділ є)
  • додати список віршів
  • малюнки на полях, що з'являються у міру розповіді (Пушкін любив малювати, у різних авторів були б свої; заділ для цього у файловій структурі є)
  • додати звуковий супровід у вигляді класичної музики під настрій вірша (в метаданих вірша є поле "mood" саме для цього). Якщо хтось знає хороший джерело Creative Commons музики, де можна знайти класику у гарному якості, прохання поділитися.
Дякую за увагу! Сподіваюся, кому-то цей проект здасться цікавим і принесе позитивні емоції :)
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.