├── .babelrc
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── LICENSE
├── README.md
├── icons
├── icon.svg
├── icon128x128.png
├── icon16x16.png
├── icon256x256.png
├── icon32x32.png
├── icon48x48.png
├── icon512x512.png
└── icon60x60.png
├── index.html
├── karma.conf.js
├── manifest.webapp
├── package.json
├── screenshots
└── screenshot-1.png
├── scripts
├── auth.js
├── components
│ └── App.js
├── index.js
├── models.js
└── store.js
├── server.js
├── styles
├── fira.less
├── fonts
│ ├── FiraMono-Regular.eot
│ ├── FiraSans-Bold.eot
│ ├── FiraSans-Bold.woff
│ ├── FiraSans-Bold.woff2
│ ├── FiraSans-Regular.eot
│ ├── FiraSans-Regular.woff
│ └── FiraSans-Regular.woff2
├── loader.less
└── main.less
├── test
├── auth_test.js
├── components
│ └── app_test.js
├── models_test.js
└── store_test.js
├── tests.webpack.js
├── webpack.config.js
└── webpack.prod.config.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | npm-debug.log
2 | node_modules
3 | assets
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | sudo: false
3 | node_js:
4 | - '0.12'
5 | addons:
6 | firefox: "39.0"
7 | before_install:
8 | - export DISPLAY=:99.0
9 | - sh -e /etc/init.d/xvfb start
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [Unreleased](https://github.com/leplatrem/Routina/tree/HEAD)
4 |
5 | [Full Changelog](https://github.com/leplatrem/Routina/compare/1.2...HEAD)
6 |
7 | Nothing changed yet.
8 |
9 | ## [1.2](https://github.com/leplatrem/Routina/tree/1.1) (2015-10-06)
10 |
11 | **New features**
12 |
13 | - Add link to allow authentication on Firefox OS [\#30](https://github.com/leplatrem/Routina/pulls/30)
14 | - Offer to load samples when list is empty [\#26](https://github.com/leplatrem/Routina/pulls/26)
15 |
16 | **Bug fixes**
17 |
18 | - Fix form fields placeholders [\#25](https://github.com/leplatrem/Routina/pulls/25)
19 | - Fix loader apparence and width
20 |
21 | ## [1.1](https://github.com/leplatrem/Routina/tree/1.1) (2015-08-20)
22 |
23 | **New features**
24 |
25 | - Show permalink to ease sharing of lists [\#19](https://github.com/leplatrem/Routina/pulls/19)
26 | - Add weeks period [\#21](https://github.com/leplatrem/Routina/pulls/21)
27 | - Auto-detect online/offline status [\#22](https://github.com/leplatrem/Routina/pulls/22)
28 | - Auto-sync while online [\#24](https://github.com/leplatrem/Routina/pulls/24)
29 |
30 | **Bug fixes**
31 |
32 | - Fixes in development environment [\#18](https://github.com/leplatrem/Routina/pulls/18)
33 | - Minor fixes in margins and apparence [\#18](https://github.com/leplatrem/Routina/pulls/18)
34 |
35 | ## [1.0](https://github.com/leplatrem/Routina/tree/1.0) (2015-08-18)
36 |
37 | **Initial version**
38 |
39 | - Integration of kinto-react-boilerplate [\#9](https://github.com/leplatrem/Routina/issues/9)
40 | - Basic synchronization on Mozilla Kinto demo server using Basic Authentication
41 | - Routina models [\#1](https://github.com/leplatrem/Routina/issues/1)
42 | - Autorefresh list [\#10](https://github.com/leplatrem/Routina/issues/10)
43 | - Mobile first UI with Bootstrap [\#11](https://github.com/leplatrem/Routina/issues/11)
44 | - Packaging for Firefox OS [\#6](https://github.com/leplatrem/Routina/issues/6)
45 |
46 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2015 - Mathieu Leplatre
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Routina
2 |
3 | > Periodical tasks, rescheduled when checked.
4 |
5 | [](https://travis-ci.org/leplatrem/Routina)
6 |
7 | **Routina** is a *todo list* where the tasks have a period and are
8 | automatically rescheduled when marked as done.
9 |
10 | It works offline.
11 |
12 | You can use it [in your browser](https://leplatrem.github.io/Routina/) or [install it as an app](https://leplatrem.github.io/Routina/install.html) (*Firefox Desktop/Android/OS*)!
13 |
14 | 
15 |
16 | ## Roadmap
17 |
18 | * Firefox Account [login](https://github.com/Kinto/kinto-react-boilerplate/pull/16) (#2) and [Firefox OS integration](https://developer.mozilla.org/en-US/docs/Firefox-Accounts-on-FirefoxOS) (#13)
19 | * Synchronize automatically when user is online (#4)
20 | * [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) (#16)
21 | * [Alarm API](https://developer.mozilla.org/en-US/docs/Web/API/Alarm_API) notifications (#17)
22 | * Settings page to control remote server and options (#15)
23 | * Encrypt data before sync (#14)
24 |
25 | ## Hack
26 |
27 | Development
28 |
29 | * ``npm install``: Install dependencies
30 | * ``npm start``: Run app locally (with autorefresh)
31 | * ``npm test``: Run tests
32 |
33 | Production
34 |
35 | * ``npm run build``: Package every assets for production
36 | * ``npm run package``: Open Web app package
37 |
38 | ## Credits
39 |
40 | Original idea by [Elisenda](http://github.com/elisenda/).
41 |
42 | 
43 | * Clock by [Micthev](https://commons.wikimedia.org/wiki/File:Clock_02-30.svg), CC-BY-SA
44 | * Mouse by [Xfce Team](https://commons.wikimedia.org/wiki/File:Xfce_logo-footprint.svg), CC-BY-SA
45 |
46 | This application is based on the [Kinto React boilerplate](https://github.com/Kinto/kinto-react-boilerplate).
47 |
48 | Data is synchronized on the [Mozilla Kinto demo server](http://kinto.readthedocs.org).
49 |
50 | ## License
51 |
52 | * Apache License Version 2.0
53 |
--------------------------------------------------------------------------------
/icons/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
1088 |
--------------------------------------------------------------------------------
/icons/icon128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon128x128.png
--------------------------------------------------------------------------------
/icons/icon16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon16x16.png
--------------------------------------------------------------------------------
/icons/icon256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon256x256.png
--------------------------------------------------------------------------------
/icons/icon32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon32x32.png
--------------------------------------------------------------------------------
/icons/icon48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon48x48.png
--------------------------------------------------------------------------------
/icons/icon512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon512x512.png
--------------------------------------------------------------------------------
/icons/icon60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/icons/icon60x60.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Routina
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | module.exports = function (config) {
2 | config.set({
3 | browsers: ["Firefox"],
4 | singleRun: true,
5 | frameworks: ["chai", "mocha", "sinon"],
6 | plugins: [
7 | "karma-firefox-launcher",
8 | "karma-chai",
9 | "karma-mocha",
10 | "karma-sinon",
11 | "karma-sourcemap-loader",
12 | "karma-webpack",
13 | ],
14 | files: [
15 | "tests.webpack.js"
16 | ],
17 | preprocessors: {
18 | "tests.webpack.js": ["webpack", "sourcemap"]
19 | },
20 | reporters: ["dots"],
21 | webpack: {
22 | devtool: ["eval", "inline-source-map"],
23 | module: {
24 | loaders: [
25 | {
26 | test: /\.js$/,
27 | loader: "babel-loader",
28 | exclude: /node_modules/
29 | }
30 | ]
31 | }
32 | },
33 | webpackServer: {
34 | noInfo: true
35 | }
36 | });
37 | };
38 |
--------------------------------------------------------------------------------
/manifest.webapp:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Routina",
3 | "version": "1.1",
4 | "installs_allowed_from": ["*"],
5 | "description": "Periodical tasks, rescheduled when checked.",
6 | "launch_path": "/index.html",
7 | "default_locale": "en",
8 | "permissions": {
9 | "storage": {
10 | "description": "Required to use the application while offline"
11 | },
12 | "alarms": {
13 | "description": "Required to schedule upcoming tasks"
14 | },
15 | "desktop-notification": {
16 | "description": "Required to alert about overdue tasks"
17 | }
18 | },
19 | "icons": {
20 | "16": "/icons/icon16x16.png",
21 | "32": "/icons/icon32x32.png",
22 | "48": "/icons/icon48x48.png",
23 | "60": "/icons/icon60x60.png",
24 | "128": "/icons/icon128x128.png",
25 | "256": "/icons/icon256x256.png",
26 | "512": "/icons/icon512x512.png"
27 | },
28 | "developer": {
29 | "name": "Mathieu Leplatre",
30 | "url": "http://mathieu-leplatre.info"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "routina",
3 | "version": "1.1.0",
4 | "description": "Periodical tasks, rescheduled when checked",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node server.js",
8 | "test": "node_modules/.bin/mocha --compilers js:babel/register 'test/*_test.js' && node_modules/.bin/karma start",
9 | "build": "NODE_ENV=production node_modules/.bin/webpack --optimize-minimize --config webpack.prod.config.js",
10 | "package": "npm run build && zip -r routina.zip assets icons index.html manifest.webapp"
11 | },
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/leplatrem/Routina.git"
15 | },
16 | "keywords": [
17 | "kinto",
18 | "react",
19 | "todolist"
20 | ],
21 | "author": "Mathieu Leplatre ",
22 | "license": "APL-2.0",
23 | "bugs": {
24 | "url": "https://github.com/leplatrem/Routina/issues"
25 | },
26 | "homepage": "https://github.com/leplatrem/Routina#readme",
27 | "dependencies": {
28 | "bootstrap": "^3.3.5",
29 | "btoa": "^1.1.2",
30 | "kinto": "^1.0.0-rc.5",
31 | "moment": "^2.10.6",
32 | "react": "^0.13.3",
33 | "uuid": "^2.0.1"
34 | },
35 | "devDependencies": {
36 | "babel": "^5.8.20",
37 | "babel-loader": "^5.3.2",
38 | "chai": "^3.2.0",
39 | "css-loader": "^0.15.6",
40 | "file-loader": "^0.8.4",
41 | "karma": "^0.13.3",
42 | "karma-chai": "^0.1.0",
43 | "karma-cli": "^0.1.0",
44 | "karma-firefox-launcher": "^0.1.6",
45 | "karma-mocha": "^0.2.0",
46 | "karma-sinon": "^1.0.4",
47 | "karma-sourcemap-loader": "^0.3.5",
48 | "karma-webpack": "^1.7.0",
49 | "less": "^2.5.1",
50 | "less-loader": "^2.2.0",
51 | "mocha": "^2.2.5",
52 | "react-hot-loader": "^1.2.8",
53 | "sinon": "git://github.com/uberVU/Sinon.JS.git#0aaf834c9f",
54 | "style-loader": "^0.12.3",
55 | "url-loader": "^0.5.6",
56 | "webpack": "^1.10.5",
57 | "webpack-dev-server": "^1.10.1"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/screenshots/screenshot-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/screenshots/screenshot-1.png
--------------------------------------------------------------------------------
/scripts/auth.js:
--------------------------------------------------------------------------------
1 | import btoa from "btoa";
2 | import { v4 as uuid4 } from "uuid";
3 | import { EventEmitter } from "events";
4 |
5 |
6 | export class Auth extends EventEmitter {
7 |
8 | constructor(server, store) {
9 | super();
10 | this.server = server;
11 | this.store = store;
12 | this.headers = {};
13 | this.token = null;
14 | }
15 |
16 | loginURI(website) {
17 | const login = this.server.replace("v1", "v1/fxa-oauth/login?redirect=");
18 | const currentWebsite = website.replace(/#.*/, '');
19 | const redirect = encodeURIComponent(currentWebsite + '#fxa:');
20 | return login + redirect;
21 | }
22 |
23 | authenticate(token='') {
24 | // Take last token from store or generate BasicAuth user with uuid4.
25 | if (!token) {
26 | token = this.store.getItem("lastToken") || uuid4();
27 | }
28 | this.token = token;
29 | this.store.setItem("lastToken", token);
30 | this.authenticated = false;
31 |
32 | if (token.indexOf('fxa:') === 0) {
33 | // Fxa token passed in URL from redirection.
34 | let bearerToken = token.replace('fxa:', '');
35 | this.headers.Authorization = 'Bearer ' + bearerToken;
36 | this.authenticated = true;
37 | this.token = ''; // Forget token.
38 | this.userid = ''; // XXX: fetch from profile server.
39 | }
40 | else {
41 | // Token provided via hash, but no FxA.
42 | // Use Basic Auth as before.
43 | let userpass64 = btoa(token + ":s3cr3t");
44 | this.userid = userpass64;
45 | this.headers.Authorization = 'Basic ' + userpass64;
46 | }
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/scripts/components/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import moment from "moment";
3 |
4 |
5 | import { Routine } from "../../scripts/models";
6 |
7 |
8 | export class Form extends React.Component {
9 |
10 | constructor(props) {
11 | super(props);
12 | this.state = {record: this.props.record || this.newRoutine()};
13 | }
14 |
15 | newRoutine() {
16 | return new Routine("", {value: 3, unit: "days"});
17 | }
18 |
19 | onFormSubmit(event) {
20 | event.preventDefault();
21 | this.props.saveRecord(this.state.record);
22 | this.setState({record: this.newRoutine()});
23 | }
24 |
25 | onChange(field, event) {
26 | var value = event.target.value;
27 | switch (field) {
28 | case "value":
29 | value = parseInt(value, 10);
30 | case "unit":
31 | Object.assign(this.state.record.period, {[field]: value});
32 | break;
33 | default:
34 | Object.assign(this.state.record, {[field]: value});
35 | }
36 | this.setState({record: this.state.record});
37 | }
38 |
39 | render() {
40 | const record = this.state.record;
41 | const creation = !record.id;
42 | return (
43 |
63 | );
64 | }
65 | }
66 |
67 |
68 | export class Item extends React.Component {
69 |
70 | static get defaultProps() {
71 | return {
72 | onEdit: () => {},
73 | onDelete: () => {},
74 | onSave: () => {}
75 | };
76 | }
77 |
78 | onCheck() {
79 | this.props.item.check();
80 | this.props.onSave(this.props.item);
81 | }
82 |
83 | render() {
84 | if (this.props.editing) {
85 | return (
86 |
87 |
90 |
92 | );
93 | }
94 |
95 | const shorten = time => time ? time.replace(/minutes?/, "min.")
96 | .replace(/seconds?/, "sec.") : "";
97 |
98 | const item = this.props.item;
99 | const next = moment(item.next).fromNow().replace("ago", "late");
100 | const last = item.last ? shorten(moment(item.last).fromNow()) : "Never";
101 | const unit = shorten(item.period.unit);
102 |
103 | return (
104 |
105 |
108 |
109 | {item.label}
110 | {next}
111 |
112 |
113 |
114 | Every
115 | {item.period.value}
116 | {unit}
117 |
118 | {last}
119 |
120 |
123 |
124 | );
125 | }
126 | }
127 |
128 |
129 | export class List extends React.Component {
130 |
131 | static get defaultProps() {
132 | return {
133 | editItem: () => {},
134 | updateRecord: () => {},
135 | deleteRecord: () => {},
136 | };
137 | }
138 |
139 | constructor(props) {
140 | super(props);
141 | this.state = {};
142 | }
143 |
144 | onEdit(index) {
145 | this.setState({current: index});
146 | this.props.editItem(index);
147 | }
148 |
149 | onSave(record) {
150 | this.setState({current: null});
151 | this.props.updateRecord(record);
152 | }
153 |
154 | onDelete(record) {
155 | this.setState({current: null});
156 | this.props.deleteRecord(record);
157 | }
158 |
159 | render() {
160 | return (
161 | {
162 | this.props.items.map((item, i) => {
163 | return ;
169 | })}
170 |
171 | );
172 | }
173 | }
174 |
175 |
176 | export default class App extends React.Component {
177 |
178 | constructor(props) {
179 | super(props);
180 | this.state = this.props.store.state;
181 |
182 | this.props.store.load();
183 | if (this.props.auth) {
184 | this.syncRecords();
185 | }
186 | }
187 |
188 | componentDidMount() {
189 | this.props.store.on("online", state => {
190 | this.setState({online: state});
191 | });
192 | this.props.store.on("busy", state => {
193 | this.setState(Object.assign({busy: state}, state ? {error: ""} : {}));
194 | });
195 | this.props.store.on("change", state => {
196 | this.props.store.autorefresh = true;
197 | this.setState(state);
198 | });
199 | this.props.store.on("error", error => {
200 | this.props.store.autorefresh = true;
201 | this.setState({error: error.message});
202 | });
203 | }
204 |
205 | addSamples() {
206 | const samples = [
207 | new Routine("Smile", {value: 40, unit: "seconds"}),
208 | new Routine("Stretch neck", {value: 2, unit: "hours"}),
209 | new Routine("Call mum", {value: 5, unit: "days"}),
210 | new Routine("Gym", {value: 1, unit: "weeks"}),
211 | new Routine("Eat cheesecake", {value: 3, unit: "weeks"}),
212 | new Routine("Change bed sheets", {value: 15, unit: "days"}),
213 | new Routine("Periods", {value: 28, unit: "days"}),
214 | new Routine("Water cactus", {value: 8, unit: "weeks"}),
215 | new Routine("Visit dentist", {value: 6, unit: "months"}),
216 | new Routine("Tree blossom", {value: 1, unit: "years"}),
217 | ];
218 | samples.map(this.createRecord.bind(this));
219 | }
220 |
221 | editItem() {
222 | // Stop auto-refresh while item is being edited.
223 | this.props.store.autorefresh = false;
224 | }
225 |
226 | createRecord(record) {
227 | this.props.store.create(record);
228 | }
229 |
230 | deleteRecord(record) {
231 | this.props.store.delete(record);
232 | }
233 |
234 | updateRecord(record) {
235 | this.props.store.update(record);
236 | }
237 |
238 | syncRecords() {
239 | this.props.store.sync();
240 | }
241 |
242 | render() {
243 | const busy = this.state.busy;
244 | const online = this.state.online;
245 |
246 | const syncDisabled = (busy || !online) ? "disabled" : "";
247 | const syncIcon = this.state.online ? (busy ? "refresh" : "cloud-upload") : "alert";
248 | const syncLabel = this.state.online ? (busy ? "Syncing..." : "Sync") : "Offline";
249 |
250 | var signIn = '';
251 | var permaLink = '';
252 | if (this.props.auth) {
253 | if (!this.props.auth.authenticated) {
254 | signIn = (
255 |
256 |
257 | Sign in
258 |
259 | );
260 | }
261 | if (this.props.auth.token) {
262 | permaLink = (
263 |
264 |
265 | Permalink
266 |
267 | );
268 | }
269 | }
270 |
271 | return (
272 |
273 | {busy ? : ""}
274 | {this.state.error}
275 | { (this.state.items.length > 0 || busy) ?
276 | :
280 |
281 |
No items found.
282 |
283 |
284 | }
285 |
286 |
288 |
289 |
293 | {permaLink}
294 | {signIn}
295 |
296 |
297 | );
298 | }
299 | }
300 |
--------------------------------------------------------------------------------
/scripts/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Webpack inclusions.
3 | */
4 | require("bootstrap/less/bootstrap.less");
5 | require("../styles/main.less");
6 |
7 | /*
8 | * Routina.
9 | */
10 | import "babel/polyfill";
11 | import React from "react";
12 | import App from "./components/App";
13 | import { Store } from "./store";
14 | import { Auth } from "./auth";
15 | import Kinto from "kinto";
16 |
17 | const server = "https://kinto.dev.mozaws.net/v1";
18 |
19 | // Migrate from Routina v1.1.
20 | window.localStorage.setItem("lastToken", (window.localStorage.getItem("lastuser") ||
21 | window.localStorage.getItem("lastToken")));
22 |
23 | // Authenticate using location hash.
24 | const auth = new Auth(server, window.localStorage);
25 | auth.authenticate(window.location.hash.slice(1));
26 | window.location.hash = auth.token;
27 |
28 | const headers = Object.assign({}, auth.headers);
29 | const kinto = new Kinto({remote: server, dbPrefix: auth.userid, headers: headers});
30 |
31 | const store = new Store(kinto, "routina-v1");
32 | store.online = window.navigator.onLine;
33 | window.addEventListener("offline", () => {store.online = false;});
34 | window.addEventListener("online", () => {store.online = true;});
35 |
36 | React.render(, document.getElementById("app"));
37 |
--------------------------------------------------------------------------------
/scripts/models.js:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 |
3 |
4 | export class Routine {
5 |
6 | /**
7 | * A periodic task that is automatically rescheduled
8 | * when checked.
9 | * @param {String} label The task label.
10 | * @return {Object} period An object with two properties
11 | ``value`` and ``unit``.
12 | */
13 | constructor (label, period={}) {
14 | this.label = label;
15 | this.period = period;
16 | this.occurences = [];
17 | }
18 |
19 | /**
20 | * Status constants.
21 | */
22 | static get status() {
23 | return {
24 | OK: "ok",
25 | WARNING: "warning",
26 | OVERDUE: "overdue",
27 | CRITICAL: "critical",
28 | };
29 | }
30 |
31 | /**
32 | * Period unit constants.
33 | */
34 | static get units() {
35 | return ["seconds", "minutes", "hours", "days", "weeks", "months", "years"];
36 | }
37 |
38 | /**
39 | * Serialize object with occurences as ISO strings.
40 | * @return {Object} flat mapping.
41 | */
42 | serialize() {
43 | const serialized = Object.assign({}, this);
44 | serialized.occurences = serialized.occurences.map(d => d.toISOString());
45 | return serialized;
46 | }
47 |
48 | /**
49 | * Deserialize mapping into a Routine object.
50 | * @param {Object} flat mapping.
51 | * @return {Routine} instantiated object.
52 | */
53 | static deserialize(serialized) {
54 | const routine = new Routine();
55 | Object.assign(routine, serialized);
56 | routine.occurences = routine.occurences.map(d => new Date(d));
57 | return routine;
58 | }
59 |
60 | get _period () {
61 | return moment.duration(this.period.value, this.period.unit);
62 | }
63 |
64 | /**
65 | * Returns the current status, critical for largely missed
66 | * occurence, warning if close, ok if far.
67 | */
68 | get status() {
69 | let period = this._period.as('seconds');
70 | let threshold = period * 0.1;
71 |
72 | if (this.timeleft < -period) {
73 | return Routine.status.CRITICAL;
74 | }
75 | if (this.timeleft < -threshold) {
76 | return Routine.status.OVERDUE;
77 | }
78 | if (this.timeleft < threshold) {
79 | return Routine.status.WARNING;
80 | }
81 | return Routine.status.OK;
82 | }
83 |
84 | /**
85 | * Returns the number of seconds since last occurence.
86 | * @return {Integer}
87 | */
88 | get elapsed () {
89 | return moment()
90 | .diff(moment(this.last), 'seconds');
91 | }
92 |
93 | /**
94 | * Returns the number of seconds until next occurence.
95 | * @return {Integer}
96 | */
97 | get timeleft () {
98 | return moment(this.next)
99 | .diff(moment(), 'seconds');
100 | }
101 |
102 | /**
103 | * Returns the last occurence.
104 | * @return {Date}
105 | */
106 | get last() {
107 | return this.occurences[this.occurences.length - 1];
108 | }
109 |
110 | /**
111 | * Returns the next occurence.
112 | * @return {Date}
113 | */
114 | get next() {
115 | return moment(this.last)
116 | .add(this._period)
117 | .toDate();
118 | }
119 |
120 | /**
121 | * Mark the routine as done, and reschedule the next one.
122 | */
123 | check() {
124 | this.occurences.push(new Date());
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/scripts/store.js:
--------------------------------------------------------------------------------
1 | import { EventEmitter } from "events";
2 | import Kinto from "kinto";
3 |
4 | import { Routine } from "./models";
5 |
6 |
7 | export class Store extends EventEmitter {
8 |
9 | constructor(kinto, collection) {
10 | super();
11 | this.state = {items: [], online: true, busy: false};
12 | this.collection = kinto.collection(collection);
13 | }
14 |
15 | deserialize(data) {
16 | return Routine.deserialize(data);
17 | }
18 |
19 | serialize(record) {
20 | return record.serialize();
21 | }
22 |
23 | set online(state) {
24 | const changed = (this.state.online !== state);
25 | this.state.online = state;
26 | if (changed) this.emit("online", state);
27 |
28 | if (changed && state && this.autorefresh) {
29 | this.sync();
30 | }
31 | }
32 |
33 | get online () {
34 | return this.state.online;
35 | }
36 |
37 | set busy(state) {
38 | const changed = (this.state.busy !== state);
39 | this.state.busy = state;
40 | if (changed) this.emit("busy", state);
41 | }
42 |
43 | get busy () {
44 | return this.state.busy;
45 | }
46 |
47 | set autorefresh(state) {
48 | if (state) {
49 | if (!this._timer)
50 | this._timer = setInterval(this.emitChange.bind(this), 5000);
51 | }
52 | else if (this._timer) {
53 | this._timer = clearInterval(this._timer);
54 | }
55 | }
56 |
57 | get autorefresh() {
58 | return !!this._timer;
59 | }
60 |
61 | onError(error) {
62 | this.busy = false;
63 | this.emit("error", error);
64 | }
65 |
66 | emitChange(options={autosync: false}) {
67 | const sorted = this.state.items.sort((a, b) => a.timeleft - b.timeleft);
68 | this.emit("change", this.state);
69 |
70 | if (options.autosync && this.autorefresh) {
71 | this.sync();
72 | }
73 | }
74 |
75 | load() {
76 | return this.collection.list()
77 | .then(res => {
78 | const instances = res.data.map(this.deserialize);
79 | this.state.items = instances;
80 | this.emitChange();
81 | })
82 | .catch(this.onError.bind(this));
83 | }
84 |
85 | create(record) {
86 | return this.collection.create(this.serialize(record))
87 | .then(res => {
88 | const instance = this.deserialize(res.data);
89 | this.state.items.push(instance);
90 | this.emitChange({autosync: true});
91 | })
92 | .catch(this.onError.bind(this));
93 | }
94 |
95 | update(record) {
96 | return this.collection.update(this.serialize(record))
97 | .then(res => {
98 | this.state.items = this.state.items.map(item => {
99 | const instance = this.deserialize(res.data);
100 | return item.id === record.id ? instance : item;
101 | });
102 | this.emitChange({autosync: true});
103 | })
104 | .catch(this.onError.bind(this));
105 | }
106 |
107 | delete(record) {
108 | return this.collection.delete(record.id)
109 | .then(res => {
110 | this.state.items = this.state.items.filter(item => {
111 | return item.id !== record.id;
112 | });
113 | this.emitChange({autosync: true});
114 | })
115 | .catch(this.onError.bind(this));
116 | }
117 |
118 | sync() {
119 | if (!this.state.online) {
120 | return;
121 | }
122 | if (this.state.busy) {
123 | // XXX: re-schedule another sync.
124 | return;
125 | }
126 |
127 | this.busy = true;
128 | return this.collection.sync({strategy: Kinto.syncStrategy.SERVER_WINS})
129 | .then((res) => {
130 | this.busy = false;
131 | if (res.ok) {
132 | return this.load();
133 | }
134 | })
135 | .catch(this.onError.bind(this));
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | var webpack = require('webpack');
2 | var WebpackDevServer = require('webpack-dev-server');
3 | var config = require('./webpack.config');
4 | var path = require("path");
5 |
6 | new WebpackDevServer(webpack(config), {
7 | contentBase: path.resolve(__dirname),
8 | publicPath: config.output.publicPath,
9 | hot: true,
10 | historyApiFallback: true
11 | }).listen(3000, '0.0.0.0', function (err, result) {
12 | if (err) {
13 | console.log(err);
14 | }
15 |
16 | console.log('Listening at localhost:3000');
17 | });
18 |
--------------------------------------------------------------------------------
/styles/fira.less:
--------------------------------------------------------------------------------
1 | @font-face{
2 | font-family: 'Fira Sans';
3 | src: url('fonts/FiraSans-Regular.eot');
4 | src: local('Fira Sans Regular'),
5 | url('fonts/FiraSans-Regular.woff') format('woff'),
6 | url('fonts/FiraSans-Regular.woff2') format('woff2');
7 | font-weight: 400;
8 | font-style: normal;
9 | }
10 |
11 |
12 | @font-face{
13 | font-family: 'Fira Sans';
14 | src: url('fonts/FiraSans-Bold.eot');
15 | src: local('Fira Sans Bold'),
16 | url('fonts/FiraSans-Bold.woff') format('woff'),
17 | url('fonts/FiraSans-Bold.woff2') format('woff2');
18 | font-weight: 700;
19 | font-style: normal;
20 | }
21 |
--------------------------------------------------------------------------------
/styles/fonts/FiraMono-Regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraMono-Regular.eot
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Bold.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.eot
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.woff
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Bold.woff2
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.eot
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.woff
--------------------------------------------------------------------------------
/styles/fonts/FiraSans-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/leplatrem/Routina/63def08799dbf033b8238e765b851acc776a14ef/styles/fonts/FiraSans-Regular.woff2
--------------------------------------------------------------------------------
/styles/loader.less:
--------------------------------------------------------------------------------
1 | /*
2 | * source: http://codepen.io/brunjo/pen/XJmbNz
3 | */
4 | @height: 3px;
5 | @color: #2980b9;
6 |
7 | .loader {
8 | height: 4px;
9 | width: 100%;
10 | left: 0px;
11 | top: 0px;
12 | position: fixed;
13 | overflow: hidden;
14 | background-color: #ddd;
15 | z-index: 999;
16 |
17 | &:before{
18 | display: block;
19 | position: absolute;
20 | content: "";
21 | left: -200px;
22 | width: 200px;
23 | height: 4px;
24 | background-color: @color;
25 | animation: loading 2s linear infinite;
26 | }
27 | }
28 | @keyframes loading {
29 | from {left: -200px; width: 30%;}
30 | 50% {width: 30%;}
31 | 70% {width: 70%;}
32 | 80% { left: 50%;}
33 | 95% {left: 120%;}
34 | to {left: 100%;}
35 | }
36 |
--------------------------------------------------------------------------------
/styles/main.less:
--------------------------------------------------------------------------------
1 | @import "./fira.less";
2 | @import "./loader.less";
3 |
4 |
5 | /**
6 | * Utils
7 | */
8 |
9 | .nopad {
10 | padding-left: 0px;
11 | padding-right: 0px;
12 | }
13 |
14 | .hbox {
15 | display: flex;
16 | flex-direction: row;
17 | justify-content: space-between;
18 | align-items: center;
19 |
20 | & > *, & > *:first-child {
21 | margin-left: @spacing;
22 | }
23 | & > *:last-child {
24 | margin-right: @spacing;
25 | }
26 | }
27 |
28 | .fit {
29 | flex-grow: 1;
30 | }
31 |
32 | .pull-end {
33 | margin-left: auto;
34 | }
35 |
36 |
37 | /*
38 | * Routina style.
39 | */
40 |
41 | @font: Fira Sans;
42 | @error: red;
43 | @annotation: #888;
44 | @decoration: #ddd;
45 |
46 | @spacing: 4px;
47 |
48 | @ok: green;
49 | @warning: #ffd800;
50 | @overdue: #FF7E00;
51 | @critical: #d70000;
52 |
53 |
54 | /* Check button Less macro */
55 | .status (@color) {
56 | box-shadow: inset 4px 0 0 0 @color;
57 | background-color: fadeout(@color, 80%);
58 | color: @color;
59 | }
60 |
61 |
62 | html, body {
63 | width: 100%;
64 | padding: 0px;
65 | margin-bottom: @spacing;
66 | font-family: @font;
67 | }
68 |
69 | .btn.default {
70 | background-color: transparent;
71 | border-color: @decoration;
72 | }
73 |
74 | .error {
75 | color: @error;
76 | }
77 |
78 | li {
79 |
80 | &.hbox {
81 | align-items: stretch;
82 | }
83 |
84 | &.ok button.check {
85 | .status(@ok);
86 | }
87 |
88 | &.warning button.check {
89 | .status(@warning);
90 | }
91 |
92 | &.overdue button.check {
93 | .status(@overdue);
94 | }
95 |
96 | &.critical button.check {
97 | .status(@critical);
98 | }
99 |
100 | .routine {
101 | font-size: 1.2em;
102 | font-weight: bold;
103 | }
104 |
105 | .next {
106 | color: @annotation;
107 | padding-left: 2px;
108 | display: block;
109 | }
110 |
111 | .details {
112 | align-self: center;
113 |
114 | & > span {
115 | display: block;
116 | width: 100%;
117 | text-align: center;
118 | }
119 |
120 | .last {
121 | color: @annotation;
122 |
123 | &::before {
124 | content: "(";
125 | }
126 | &::after {
127 | content: ")";
128 | }
129 | }
130 |
131 | .period {
132 | .value::before,
133 | .unit::before {
134 | content: "\a0";
135 | }
136 | }
137 | }
138 | }
139 |
140 | .samples {
141 | h1 {
142 | color: @decoration;
143 | }
144 | text-align: center;
145 | margin-bottom: 1em;
146 | }
147 |
148 | .create {
149 | margin-bottom: 20px;
150 | padding-left: @spacing;
151 | padding-right: @spacing;
152 |
153 | form {
154 | padding: @spacing 0px;
155 | border-radius: 4px;
156 | border: 1px solid @decoration;
157 | }
158 | }
159 |
160 | form {
161 |
162 | &.hbox, &.hbox:last-child {
163 | margin: 0px;
164 | }
165 |
166 | .form-control {
167 | margin-left: @spacing;
168 | padding: 6px 3px;
169 | }
170 |
171 | input[name='label'] {
172 | min-width: 0;
173 | display: inline;
174 | }
175 |
176 | input[name='value'] {
177 | max-width: 4em;
178 | }
179 |
180 | button[type='submit'] {
181 | margin-left: @spacing;
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/test/auth_test.js:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import sinon from "sinon";
3 |
4 | import { Auth } from "../scripts/auth";
5 |
6 |
7 | class FakeStorage {
8 | setItem() {}
9 | getItem() {}
10 | }
11 |
12 |
13 | describe("Auth", () => {
14 |
15 | var sandbox;
16 | var auth;
17 | var store;
18 |
19 | beforeEach(() => {
20 | sandbox = sinon.sandbox.create();
21 | store = new FakeStorage();
22 | auth = new Auth('http://server/v1', store);
23 | });
24 |
25 | afterEach(() => {
26 | sandbox.restore();
27 | });
28 |
29 |
30 | describe("#loginURI", () => {
31 |
32 | it("contains an encoded version of URL with hash", () => {
33 | var result = auth.loginURI("http://routina.com");
34 | var base = "http://server/v1/fxa-oauth/login?redirect="
35 | expect(result).to.eql(base + "http%3A%2F%2Froutina.com%23fxa%3A");
36 | });
37 |
38 | });
39 |
40 |
41 | describe("#authenticate()", () => {
42 |
43 | it("takes last token from store to authenticate", () => {
44 | sandbox.stub(store, "getItem").returns('existing');
45 | auth.authenticate('');
46 | expect(auth.headers.Authorization).to.eql('Basic ZXhpc3Rpbmc6czNjcjN0');
47 | });
48 |
49 | it("generates a basic token if no token in store", () => {
50 | auth.authenticate('');
51 | expect(auth.headers.Authorization).to.contain('Basic');
52 | });
53 |
54 | it("puts the token in store if provided", () => {
55 | sandbox.stub(store, "setItem");
56 | auth.authenticate('family-tasks');
57 | sinon.assert.calledWithExactly(store.setItem, 'lastToken', 'family-tasks');
58 | });
59 |
60 | it("sets bearer token if token is prefixed with fxa", () => {
61 | auth.authenticate('fxa:1234567');
62 | expect(auth.headers.Authorization).to.eql('Bearer 1234567');
63 | });
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/test/components/app_test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import TestUtils from "react/lib/ReactTestUtils";
3 | import { expect } from "chai";
4 | import sinon from "sinon";
5 | import Kinto from "kinto";
6 | import moment from "moment";
7 |
8 | import App, { Form, List, Item } from "../../scripts/components/App";
9 | import { Store } from "../../scripts/store";
10 | import { Routine } from "../../scripts/models";
11 |
12 |
13 | describe("App", () => {
14 |
15 | var sandbox;
16 | var store;
17 |
18 | beforeEach(() => {
19 | sandbox = sinon.sandbox.create();
20 | const kinto = new Kinto();
21 | store = new Store(kinto, "items");
22 | });
23 |
24 | afterEach(() => {
25 | sandbox.restore();
26 | });
27 |
28 | describe("Component", () => {
29 |
30 | var rendered;
31 |
32 | beforeEach(() => {
33 | sandbox.stub(store, "create").returns(Promise.resolve({}));
34 | sandbox.stub(store, "delete").returns(Promise.resolve({}));
35 | sandbox.stub(store, "load").returns(Promise.resolve({}));
36 | sandbox.stub(store, "update").returns(Promise.resolve({}));
37 | sandbox.stub(store, "sync").returns(Promise.resolve({}));
38 | rendered = TestUtils.renderIntoDocument();
39 | });
40 |
41 | it("loads items from store on mount", () => {
42 | sinon.assert.calledOnce(store.load);
43 | });
44 |
45 | it("enables autorefresh on load", () => {
46 | expect(store.autorefresh).to.be.false;
47 | store.emit("change", {items: []});
48 | expect(store.autorefresh).to.be.true;
49 | });
50 |
51 | it("renders items of store when store changes", () => {
52 | store.emit("change", {items: [new Routine(":)")]});
53 | let node = React.findDOMNode(rendered);
54 | expect(node.querySelector("li").textContent).to.contain(":)");
55 | });
56 |
57 | it("adds item to the store on form submit", () => {
58 | const node = React.findDOMNode(rendered).querySelector("input");
59 | TestUtils.Simulate.change(node, {target: {value: "Hello, world"}});
60 | TestUtils.Simulate.submit(node);
61 | var createArg = store.create.lastCall.args[0];
62 | expect(createArg.label).to.eql("Hello, world");
63 | });
64 |
65 |
66 | describe("Load samples", () => {
67 | beforeEach(() => {
68 | store.emit("change", {items: []});
69 | });
70 |
71 | it("show load samples button if empty", () => {
72 | let node = React.findDOMNode(rendered);
73 | expect(node.querySelector(".samples .btn")).to.exist;
74 | });
75 |
76 | it("load samples on click", () => {
77 | sinon.stub(rendered, "createRecord");
78 | const node = React.findDOMNode(rendered).querySelector(".samples .btn");
79 | TestUtils.Simulate.click(node);
80 | expect(rendered.createRecord.callCount).to.eql(10);
81 | });
82 | });
83 |
84 |
85 | describe("Sync", () => {
86 | it("syncs store on button click", () => {
87 | const node = React.findDOMNode(rendered).querySelector("button.sync");
88 | TestUtils.Simulate.click(node);
89 | sinon.assert.calledOnce(store.sync);
90 | });
91 |
92 | it("shows loader while store is busy", () => {
93 | store.emit("busy", true);
94 | const node = React.findDOMNode(rendered);
95 | expect(node.querySelector(".loader")).to.exist;
96 | });
97 |
98 | it("disables button while store is busy", () => {
99 | store.emit("busy", true);
100 | const selector = "button.sync[disabled]";
101 | const node = React.findDOMNode(rendered);
102 | expect(node.querySelector(selector)).to.exist;
103 | store.emit("busy", false);
104 | expect(node.querySelector(selector)).to.not.exist;
105 | });
106 |
107 | it("changes button label and icon while store is busy", () => {
108 | store.emit("busy", true);
109 | const node = React.findDOMNode(rendered).querySelector("button.sync");
110 | expect(node.textContent).to.contain("Syncing...");
111 | expect(node.querySelector(".glyphicon-refresh")).to.exist;
112 | });
113 |
114 | it("shows message when error happens", () => {
115 | store.emit("error", new Error("Failed"));
116 | const node = React.findDOMNode(rendered);
117 | expect(node.querySelector(".error").textContent).to.eql("Failed");
118 | });
119 |
120 | it("clears error message when store is busy", () => {
121 | store.emit("error", new Error("Failed"));
122 | store.emit("busy", true);
123 | const node = React.findDOMNode(rendered);
124 | expect(node.querySelector(".error").textContent).to.eql("");
125 | });
126 | });
127 |
128 |
129 | describe("Authenticated", () => {
130 | var auth;
131 |
132 | beforeEach(() => {
133 | auth = {loginURI: () => {}, authenticated: false};
134 | rendered = TestUtils.renderIntoDocument();
135 | });
136 |
137 | it("sync records on mount if auth is provided", () => {
138 | sinon.assert.calledOnce(store.sync);
139 | });
140 |
141 | it("shows a signin button", () => {
142 | var node = React.findDOMNode(rendered);
143 | expect(node.querySelectorAll("a.signin").length).to.eql(1);
144 | });
145 |
146 | it("shows a permalink with location hash", () => {
147 | auth.token = 'mat';
148 | rendered = TestUtils.renderIntoDocument();
149 | var node = React.findDOMNode(rendered);
150 | var selector = "a[href='https://leplatrem.github.io/Routina/#mat']"
151 | expect(node.querySelectorAll(selector).length).to.eql(1);
152 | });
153 |
154 | });
155 |
156 |
157 | describe("Offline", () => {
158 | beforeEach(() => {
159 | store.online = false;
160 | });
161 |
162 | it("disables the button while offline", () => {
163 | const selector = "button.sync[disabled]";
164 | const node = React.findDOMNode(rendered);
165 | expect(node.querySelector(selector)).to.exist;
166 | });
167 |
168 | it("shows a warning while offline", () => {
169 | const node = React.findDOMNode(rendered).querySelector("button.sync");
170 | expect(node.textContent).to.contain("Offline");
171 | expect(node.querySelector(".glyphicon-alert")).to.exist;
172 | });
173 | });
174 |
175 |
176 | describe("Editing", () => {
177 |
178 | var record;
179 |
180 | beforeEach(() => {
181 | record = new Routine("Existing");
182 | record.id = 42;
183 |
184 | // Fill list.
185 | store.emit("change", {items: [record]});
186 | const item = React.findDOMNode(rendered).querySelector("button.edit");
187 | // Set an item in edition mode.
188 | TestUtils.Simulate.click(item);
189 | });
190 |
191 | it("stops store autorefresh on edit", () => {
192 | expect(store.autorefresh).to.be.false;
193 | });
194 |
195 | it("updates item in the store on edit", () => {
196 | // Change and submit.
197 | const field = React.findDOMNode(rendered).querySelector("input[name='label']");
198 | TestUtils.Simulate.change(field, {target: {value: "Hola, mundo"}});
199 | TestUtils.Simulate.submit(field);
200 | var updateArg = store.update.lastCall.args[0];
201 | expect(updateArg.id).to.eql(42);
202 | expect(updateArg.label).to.eql("Hola, mundo");
203 | });
204 |
205 | it("deletes item from the store", () => {
206 | const button = React.findDOMNode(rendered).querySelector("button.delete");
207 | TestUtils.Simulate.click(button);
208 | sinon.assert.calledWithExactly(store.delete, record);
209 | });
210 | });
211 | });
212 | });
213 |
214 |
215 | describe("List", () => {
216 |
217 | var rendered;
218 | const items = [new Routine("Hello"), new Routine("World")];
219 |
220 | beforeEach(() => {
221 | rendered = TestUtils.renderIntoDocument();
222 | });
223 |
224 | it("renders without problems", () => {
225 | expect(React.findDOMNode(rendered).tagName).to.equal("UL");
226 | });
227 |
228 | it("renders items in list", () => {
229 | expect(React.findDOMNode(rendered).querySelectorAll("li").length).to.equal(2);
230 | });
231 |
232 |
233 | describe("Editing", () => {
234 |
235 | var node;
236 | var editItemCallback, updateCallback, deleteCallback;
237 |
238 | beforeEach(() => {
239 | editItemCallback = sinon.spy();
240 | updateCallback = sinon.spy();
241 | deleteCallback = sinon.spy();
242 | rendered = TestUtils.renderIntoDocument();
245 | node = React.findDOMNode(rendered);
246 |
247 | TestUtils.Simulate.click(node.querySelector("li:first-child button.edit"));
248 | const field = node.querySelector("input[name='label']");
249 | TestUtils.Simulate.change(field, {target: {value: "Hola, mundo"}});
250 | });
251 |
252 | it("uses callback on edit", () => {
253 | sinon.assert.calledOnce(editItemCallback);
254 | });
255 |
256 | it("renders as form on item click", () => {
257 | expect(node.querySelectorAll("form").length).to.equal(1);
258 | });
259 |
260 | it("hides form after save", () => {
261 | TestUtils.Simulate.submit(node.querySelector("form"));
262 | expect(node.querySelectorAll("form").length).to.equal(0);
263 | });
264 |
265 | it("allows editing only one item at a time", () => {
266 | TestUtils.Simulate.click(node.querySelector("li:last-child button.edit"));
267 | expect(node.querySelectorAll("form").length).to.equal(1);
268 | });
269 |
270 | it("allows editing the same item several times", () => {
271 | TestUtils.Simulate.submit(node.querySelector("form"));
272 | TestUtils.Simulate.click(node.querySelector("li:first-child button.edit"));
273 | expect(node.querySelectorAll("form").length).to.equal(1);
274 | });
275 |
276 | it("cancels edition when other item is clicked", () => {
277 | TestUtils.Simulate.click(node.querySelector("li:last-child button.edit"));
278 | const form = node.querySelectorAll("li:first-child form");
279 | expect(form.length).to.equal(0);
280 | });
281 |
282 | it("uses callback on save", () => {
283 | TestUtils.Simulate.submit(node.querySelector("form"));
284 | var updateArg = updateCallback.lastCall.args[0];
285 | expect(updateArg.label).to.eql("Hola, mundo");
286 | });
287 |
288 | it("uses callback on delete", () => {
289 | TestUtils.Simulate.click(node.querySelector("li > button"));
290 | sinon.assert.calledOnce(deleteCallback);
291 | });
292 |
293 | it("hides form after delete", () => {
294 | TestUtils.Simulate.click(node.querySelector("li > button"));
295 | expect(node.querySelectorAll("form").length).to.equal(0);
296 | });
297 | });
298 | });
299 |
300 |
301 | describe("Item", () => {
302 |
303 | var rendered;
304 | var record;
305 | var node;
306 |
307 | beforeEach(() => {
308 | record = new Routine("Value", {value: 4, unit: "days"});
309 | record.check();
310 | rendered = TestUtils.renderIntoDocument(- );
311 | node = React.findDOMNode(rendered);
312 | });
313 |
314 | it("renders without problems", () => {
315 | expect(React.findDOMNode(rendered).tagName).to.equal("LI");
316 | });
317 |
318 | it("renders routine label in a routine span", () => {
319 | expect(node.querySelector("span.routine").textContent).to.equal("Value");
320 | });
321 |
322 | it("renders routine status in item class", () => {
323 | expect(node.className).to.contain("ok");
324 | });
325 |
326 | it("renders routine period in a period span", () => {
327 | expect(node.querySelector(".period").textContent).equal("Every4days");
328 | expect(node.querySelector(".period .value").textContent).equal("4");
329 | expect(node.querySelector(".period .unit").textContent).equal("days");
330 | });
331 |
332 | it("renders routine next occurence in a next span", () => {
333 | expect(node.querySelector(".next").textContent).to.contain("in");
334 | });
335 |
336 | it("renders routine last occurence in a last span", () => {
337 | expect(node.querySelector(".last").textContent).to.contain("ago");
338 | });
339 |
340 | it("display last as never if never checked", () => {
341 | rendered = TestUtils.renderIntoDocument(
);
342 | node = React.findDOMNode(rendered);
343 | expect(node.querySelector(".last").textContent).to.eql("Never");
344 | });
345 |
346 | it("display late if task is overdue", () => {
347 | const longTimeAgo = moment().subtract(6, "days").toDate();
348 | record.occurences = [longTimeAgo];
349 | rendered = TestUtils.renderIntoDocument(- );
350 | node = React.findDOMNode(rendered);
351 | expect(node.querySelector(".next").textContent).to.contain("late");
352 | });
353 |
354 | it("uses callback on edit", () => {
355 | var callback = sinon.spy();
356 | const item = TestUtils.renderIntoDocument(
- );
357 | node = React.findDOMNode(item).querySelector("button.edit");
358 | TestUtils.Simulate.click(node);
359 | sinon.assert.calledOnce(callback);
360 | });
361 |
362 | it("checks routine and call save callback", () => {
363 | var callback = sinon.spy();
364 | const item = TestUtils.renderIntoDocument(
- );
365 | node = React.findDOMNode(item).querySelector("button.check");
366 | expect(record.occurences.length).to.eql(1);
367 | TestUtils.Simulate.click(node);
368 | expect(record.occurences.length).to.eql(2);
369 | sinon.assert.calledWithExactly(callback, record);
370 | });
371 |
372 |
373 | describe("Editing", () => {
374 |
375 | var saveCallback, deleteCallback;
376 |
377 | beforeEach(() => {
378 | saveCallback = sinon.spy();
379 | deleteCallback = sinon.spy();
380 | rendered = TestUtils.renderIntoDocument(
);
384 | });
385 |
386 | it("renders label in field value if editing", () => {
387 | var node = React.findDOMNode(rendered);
388 | expect(node.querySelector("input").value).to.equal("Value");
389 | });
390 |
391 | it("uses callback on save", () => {
392 | var newvalue = "Hello, world";
393 | var field = React.findDOMNode(rendered).querySelector("input[name='label']")
394 | TestUtils.Simulate.change(field, {target: {value: newvalue}});
395 | TestUtils.Simulate.submit(field);
396 | const saveArg = saveCallback.lastCall.args[0];
397 | expect(saveArg.label).to.eql(newvalue);
398 | });
399 |
400 | it("uses callback on delete", () => {
401 | const node = React.findDOMNode(rendered).querySelector("button.delete");
402 | TestUtils.Simulate.click(node);
403 | sinon.assert.calledOnce(deleteCallback);
404 | });
405 | });
406 | });
407 |
408 |
409 | describe("Form", () => {
410 |
411 | var rendered;
412 |
413 | beforeEach(() => {
414 | rendered = TestUtils.renderIntoDocument();
415 | });
416 |
417 | it("renders without problems", () => {
418 | expect(React.findDOMNode(rendered).tagName).to.equal("FORM");
419 | });
420 |
421 | it("shows add button if no record", () => {
422 | const button = React.findDOMNode(rendered).querySelector("button");
423 | expect(button.getAttribute("aria-label")).to.equal("Add");
424 | });
425 |
426 | it("sets placeholder if no record", () => {
427 | const button = React.findDOMNode(rendered).querySelector("input");
428 | expect(button.placeholder).to.equal("New habit");
429 | });
430 |
431 | it("contains an fields with default values and a submit button", () => {
432 | const node = React.findDOMNode(rendered);
433 | expect(node.querySelector("input[name='label']").value).to.eql("");
434 | expect(node.querySelector("input[name='value']").value).to.eql("3");
435 | expect(node.querySelector("select[name='unit']").value).to.eql("days");
436 | });
437 |
438 | it("uses callback on submit", () => {
439 | const callback = sinon.spy();
440 | rendered = TestUtils.renderIntoDocument();
465 | });
466 |
467 | it("renders record values in fields", () => {
468 | var node = React.findDOMNode(rendered);
469 | expect(node.querySelector("input[name='label']").value).to.equal("Value");
470 | expect(node.querySelector("input[name='value']").value).to.equal("12");
471 | expect(node.querySelector("select[name='unit']").value).to.equal("years");
472 | });
473 |
474 | it("updates its state when field change", () => {
475 | const newvalue = "Hola, mundo";
476 | const field = React.findDOMNode(rendered).querySelector("input[name='label']")
477 | TestUtils.Simulate.change(field, {target: {value: newvalue}});
478 | expect(rendered.state.record.label).to.equal(newvalue);
479 | });
480 |
481 | it("updates period attributes when fields change", () => {
482 | const newvalue = "days";
483 | const field = React.findDOMNode(rendered).querySelector("select[name='unit']")
484 | TestUtils.Simulate.change(field, {target: {value: newvalue}});
485 | expect(rendered.state.record.period.unit).to.equal(newvalue);
486 | });
487 |
488 | it("uses callback on submit", () => {
489 | const newvalue = "Hola, mundo";
490 | const field = React.findDOMNode(rendered).querySelector("input[name='label']")
491 | TestUtils.Simulate.change(field, {target: {value: newvalue}});
492 | TestUtils.Simulate.submit(field);
493 |
494 | const saveArgs = callback.lastCall.args[0];
495 | expect(saveArgs.label).to.eql(newvalue);
496 | expect(saveArgs.id).to.eql(record.id);
497 | });
498 |
499 | it("clears form on submit", () => {
500 | const node = React.findDOMNode(rendered);
501 | TestUtils.Simulate.submit(node);
502 | expect(rendered.state.record.label).to.equal("");
503 | });
504 | });
505 | });
506 |
--------------------------------------------------------------------------------
/test/models_test.js:
--------------------------------------------------------------------------------
1 | import { expect, Assertion } from "chai";
2 | import moment from "moment";
3 | import {Routine} from "../scripts/models";
4 |
5 |
6 | Assertion.addMethod('almost', function (value) {
7 | if (value instanceof Date) {
8 | var diff = moment(this._obj).diff(value, 'seconds');
9 | new Assertion(diff).to.be.within(-1, 1);
10 | }
11 | else {
12 | new Assertion(this._obj).to.be.within(value - 1, value + 1);
13 | }
14 | });
15 |
16 |
17 | describe("Routine", () => {
18 |
19 | var routine;
20 |
21 | beforeEach(() => {
22 | let period = {value: 4, unit: "days"};
23 | routine = new Routine("Call mum", period);
24 | });
25 |
26 |
27 | describe("New routine", () => {
28 |
29 | it("has time left equal to period in seconds", () => {
30 | let fourDays = 4 * 24 * 3600;
31 | expect(routine.timeleft).to.be.almost(fourDays);
32 | });
33 |
34 | it("has no past occurence.", () => {
35 | expect(routine.last).to.be.undefined;
36 | });
37 |
38 | it("has next occurence computed from now", () => {
39 | let inFourDays = moment().add(4, "days").toDate();
40 | expect(routine.next).to.almost(inFourDays);
41 | });
42 |
43 | it("has status ok", () => {
44 | expect(routine.status).to.eql(Routine.status.OK);
45 | });
46 | });
47 |
48 |
49 | describe("Usual routine", () => {
50 |
51 | beforeEach(() => {
52 | // Simulates checked 1 day ago.
53 | let yesterday = moment().subtract(1, "days").toDate();
54 | routine.occurences.push(yesterday);
55 | });
56 |
57 | it("has last occurence.", () => {
58 | expect(routine.last).to.exist;
59 | });
60 |
61 | it("has elapsed equal seconds since last", () => {
62 | let oneDay = 24 * 3600;
63 | expect(routine.elapsed).to.be.almost(oneDay);
64 | });
65 |
66 | it("has next occurence computed from last one", () => {
67 | let inThreeDays = moment().add(3, "days").toDate();
68 | expect(routine.next).to.be.almost(inThreeDays);
69 | });
70 |
71 | it("has time left equal to period minus elapsed", () => {
72 | let threeDays = 3 * 24 * 3600;
73 | expect(routine.timeleft).to.be.almost(threeDays);
74 | });
75 |
76 | it("is shifted from now when checked", () => {
77 | routine.check();
78 | let inFourDays = moment().add(4, "days").toDate();
79 | expect(routine.next).to.be.almost(inFourDays);
80 | });
81 |
82 | it("has status ok if next occurence is far", () => {
83 | expect(routine.status).to.eql(Routine.status.OK);
84 | });
85 |
86 | it("has status warning if next occurence is close", () => {
87 | let threeDaysAgo = moment().subtract(4, "days")
88 | .add(2, "hours")
89 | .toDate();
90 | routine.occurences.push(threeDaysAgo);
91 | expect(routine.status).to.eql(Routine.status.WARNING);
92 | });
93 | });
94 |
95 |
96 | describe("Overdue routine", () => {
97 |
98 | beforeEach(() => {
99 | // Simulates checked 6 days ago.
100 | let longTimeAgo = moment().subtract(6, "days").toDate();
101 | routine.occurences.push(longTimeAgo);
102 | });
103 |
104 | it("has negative time left", () => {
105 | expect(routine.timeleft).to.be.negative;
106 | });
107 |
108 | it("has next occurence in the past", () => {
109 | let comparison = moment(routine.next).isBefore(new Date());
110 | expect(comparison).to.be.true;
111 | });
112 |
113 | it("is shifted from now when checked", () => {
114 | routine.check();
115 | let inFourDays = moment().add(4, "days").toDate();
116 | expect(routine.next).to.be.almost(inFourDays);
117 | });
118 |
119 | it("has status warning if missed occurence is close", () => {
120 | let someTimeAgo = moment().subtract(4, "days")
121 | .subtract(2, "hours")
122 | .toDate();
123 | routine.occurences.push(someTimeAgo);
124 | expect(routine.status).to.eql(Routine.status.WARNING);
125 | });
126 |
127 | it("has status overdue if missed occurence is far", () => {
128 | expect(routine.status).to.eql(Routine.status.OVERDUE);
129 | });
130 |
131 | it("has status critical if missed occurence is more than period", () => {
132 | let someTimeAgo = moment().subtract(9, "days")
133 | .toDate();
134 | routine.occurences.push(someTimeAgo);
135 | expect(routine.status).to.eql(Routine.status.CRITICAL);
136 | });
137 | });
138 |
139 |
140 | describe("Serialization", () => {
141 | it("serializes as mapping", () => {
142 | const serialized = routine.serialize();
143 | expect(serialized).to.eql({
144 | label: "Call mum",
145 | period: {value: 4, unit: "days"},
146 | occurences: []
147 | });
148 | });
149 |
150 | it("exports occurences as list of ISO strings", () => {
151 | routine.occurences.push(new Date(Date.UTC(1995, 11, 17)));
152 | const serialized = routine.serialize();
153 | expect(serialized.occurences).to.eql(["1995-12-17T00:00:00.000Z"]);
154 | });
155 |
156 | it("exports every Kinto attributes", () => {
157 | routine.id = "42";
158 | routine._status = "sync";
159 | routine.last_modified = 234;
160 | const serialized = routine.serialize();
161 | expect(serialized.id).to.eql("42");
162 | expect(serialized._status).to.eql("sync");
163 | expect(serialized.last_modified).to.eql(234);
164 | });
165 |
166 | it("ignores class attributes", () => {
167 | const serialized = routine.serialize();
168 | expect(serialized._period).to.not.exist;
169 | });
170 | });
171 |
172 |
173 | describe("Deserialization", () => {
174 | const serialized = {
175 | label: "Call dad",
176 | period: {value: 2, unit: "months"},
177 | occurences: ["1995-12-17T00:00:00.000Z"]
178 | };
179 |
180 | it("deserializes a mapping", () => {
181 | const routine = Routine.deserialize(serialized);
182 | expect(routine.label).to.eql("Call dad");
183 | expect(routine.period.value).to.eql(2);
184 | expect(routine.period.unit).to.eql("months");
185 | });
186 |
187 | it("import occurences from ISO to native dates", () => {
188 | const routine = Routine.deserialize(serialized);
189 | expect(routine.occurences).to.eql([new Date(Date.UTC(1995, 11, 17))]);
190 | });
191 |
192 | it("import serialized attributes", () => {
193 | serialized._status = "sync";
194 | const routine = Routine.deserialize(serialized);
195 | expect(routine._status).to.eql("sync");
196 | })
197 | });
198 | });
199 |
--------------------------------------------------------------------------------
/test/store_test.js:
--------------------------------------------------------------------------------
1 | import { expect } from "chai";
2 | import sinon from "sinon";
3 | import Kinto from "kinto";
4 |
5 | import { Store } from "../scripts/store";
6 | import { Routine } from "../scripts/models";
7 |
8 |
9 | describe("Store", () => {
10 |
11 | var sandbox;
12 | var store;
13 |
14 | var sample = {id: 1, label: "Hola!"};
15 |
16 | beforeEach((done) => {
17 | sandbox = sinon.sandbox.create();
18 | const kinto = new Kinto();
19 | store = new Store(kinto, "items");
20 |
21 | sandbox.stub(store.collection, "create")
22 | .returns(Promise.resolve({data: sample}));
23 | store.create(new Routine())
24 | .then(done);
25 | });
26 |
27 | afterEach(() => {
28 | sandbox.restore();
29 | });
30 |
31 | it("uses the specified collection name", () => {
32 | store.on("change", event => {
33 | const store = new Store(kinto, "articles");
34 | expect(store.collection.name).to.equal("articles");
35 | });
36 | });
37 |
38 | describe("#load()", () => {
39 |
40 | beforeEach(() => {
41 | sandbox.stub(store.collection, "list")
42 | .returns(Promise.resolve({data: [sample]}));
43 | });
44 |
45 | it("fills items and emits change", (done) => {
46 | store.on("change", event => {
47 | expect(store.state.items).to.eql([Routine.deserialize(sample)]);
48 | done();
49 | });
50 | store.load();
51 | });
52 |
53 | it("sorts routines by timeleft", () => {
54 | const shuffled = [
55 | new Routine("1", {value: 1, unit: "days"}),
56 | new Routine("3", {value: 3, unit: "days"}),
57 | new Routine("2", {value: 2, unit: "days"}),
58 | ];
59 | store.collection.list.returns(Promise.resolve({data: shuffled}));
60 | store.on("change", event => {
61 | const sorted = store.state.items.map(r => r.label);
62 | expect(sorted).to.eql(["1", "2", "3"]);
63 | });
64 | });
65 | });
66 |
67 |
68 | describe("#create()", () => {
69 |
70 | it("adds Kinto record to its state and emits change", (done) => {
71 | store.on("change", event => {
72 | expect(event.items).to.eql([Routine.deserialize(sample), Routine.deserialize(sample)]);
73 | done();
74 | });
75 | store.create(new Routine());
76 | });
77 |
78 | it("syncs if online and autorefresh", (done) => {
79 | sandbox.stub(store.collection, "sync")
80 | .returns(Promise.resolve({ok: true}));
81 | store.autorefresh = store.online = true;
82 |
83 | store.create(new Routine());
84 | store.on('busy', state => {
85 | if (!state) {
86 | sinon.assert.calledOnce(store.collection.sync);
87 | done();
88 | }
89 | });
90 | });
91 | });
92 |
93 |
94 | describe("#update()", () => {
95 |
96 | const existing = {id: 1, label: "from db"};
97 |
98 | beforeEach(() => {
99 | sandbox.stub(store.collection, 'update')
100 | .returns(Promise.resolve({data: existing}));
101 | });
102 |
103 | it("update() replaces with Kinto record and emits change", (done) => {
104 | store.on('change', event => {
105 | expect(event.items).to.eql([Routine.deserialize(existing)]);
106 | done();
107 | });
108 |
109 | const updated = Routine.deserialize({id: 1, label: "Mundo"});
110 | store.update(updated);
111 | });
112 |
113 | it("syncs if online and autorefresh", (done) => {
114 | sandbox.stub(store.collection, "sync")
115 | .returns(Promise.resolve({ok: true}));
116 | store.autorefresh = store.online = true;
117 |
118 | store.update(new Routine());
119 | store.on('busy', state => {
120 | if (!state) {
121 | sinon.assert.calledOnce(store.collection.sync);
122 | done();
123 | }
124 | });
125 | });
126 | });
127 |
128 |
129 | describe("#delete()", () => {
130 | beforeEach(() => {
131 | sandbox.stub(store.collection, "delete")
132 | .returns(Promise.resolve({}));
133 | });
134 |
135 | it("removes record and emits change", (done) => {
136 | store.on('change', event => {
137 | expect(event.items).to.eql([]);
138 | done();
139 | });
140 | store.delete({id: 1, label: "Mundo"});
141 | });
142 |
143 | it("syncs if online and autorefresh", (done) => {
144 | sandbox.stub(store.collection, "sync")
145 | .returns(Promise.resolve({ok: true}));
146 | store.autorefresh = store.online = true;
147 |
148 | store.delete({id: 1, label: "Mundo"});
149 | store.on('busy', state => {
150 | if (!state) {
151 | sinon.assert.calledOnce(store.collection.sync);
152 | done();
153 | }
154 | });
155 | });
156 | });
157 |
158 |
159 | describe("#sync()", () => {
160 |
161 | const existing = {label: "from db"};
162 |
163 | beforeEach(() => {
164 | sandbox.stub(store.collection, "sync")
165 | .returns(Promise.resolve({ok: true}));
166 | sandbox.stub(store.collection, "list")
167 | .returns(Promise.resolve({data: [existing]}));
168 | });
169 |
170 | it("emits busy when sync starts and stops", (done) => {
171 | var callback = sinon.spy();
172 | store.on("busy", callback);
173 | store.sync()
174 | .then(() => {
175 | sinon.assert.calledTwice(callback);
176 | done();
177 | });
178 | });
179 |
180 | it("reloads the local db after sync if ok", (done) => {
181 | store.on("change", event => {
182 | expect(event.items).to.eql([Routine.deserialize(existing)]);
183 | done();
184 | });
185 | store.sync();
186 | });
187 |
188 | it("resolves conflicts using remote records", (done) => {
189 | store.on("change", event => {
190 | sinon.assert.calledWithExactly(store.collection.sync, { strategy: "server_wins" });
191 | done();
192 | });
193 | store.sync();
194 | });
195 |
196 | it("emits error when fails", (done) => {
197 | store.collection.sync.returns(Promise.reject(new Error("Server down")));
198 | store.on("error", error => {
199 | expect(error.message).to.eql("Server down");
200 | done();
201 | });
202 | store.sync();
203 | });
204 |
205 | it("does nothing while offline", () => {
206 | store.online = false;
207 | var result = store.sync();
208 | expect(result).to.not.exist;
209 | });
210 |
211 | it("does nothing while busy", () => {
212 | store.busy = true;
213 | var result = store.sync();
214 | expect(result).to.not.exist;
215 | });
216 | });
217 |
218 |
219 | describe("Online", () => {
220 | it("is online by default", () => {
221 | expect(store.online).to.be.true;
222 | });
223 |
224 | it("sends an event when going offline", () => {
225 | var callback = sinon.spy();
226 | store.on("online", callback);
227 | store.online = false;
228 | sinon.assert.calledOnce(callback);
229 | });
230 |
231 | it("sends an event only when state changes", () => {
232 | var callback = sinon.spy();
233 | store.on("online", callback);
234 | store.online = false;
235 | store.online = false;
236 | sinon.assert.calledOnce(callback);
237 | });
238 |
239 | it("is syncs when coming back online", () => {
240 | sinon.stub(store, "sync");
241 | store.autorefresh = true;
242 | store.online = false;
243 | store.online = true;
244 | sinon.assert.calledOnce(store.sync);
245 | });
246 | });
247 |
248 |
249 | describe("Autorefresh", () => {
250 | var clock;
251 | var callback;
252 |
253 | beforeEach(() => {
254 | clock = sinon.useFakeTimers();
255 | callback = sinon.spy();
256 | store.on("change", callback);
257 | });
258 |
259 | afterEach(() => {
260 | clock.restore();
261 | });
262 |
263 | it("does not autorefresh by default", () => {
264 | clock.tick(6000);
265 | expect(callback.called).to.be.false;
266 | });
267 |
268 | it("autorefreshes every 5 seconds", () => {
269 | store.autorefresh = true;
270 | clock.tick(5002);
271 | expect(callback.calledOnce).to.be.true;
272 | });
273 |
274 | it("cancels autorefresh if set to false", () => {
275 | store.autorefresh = true;
276 | clock.tick(3000);
277 | store.autorefresh = false;
278 | clock.tick(6000);
279 | expect(callback.called).to.be.false;
280 | });
281 | });
282 | });
283 |
--------------------------------------------------------------------------------
/tests.webpack.js:
--------------------------------------------------------------------------------
1 | var context = require.context('./test/components', true, /_test\.js$/);
2 | context.keys().forEach(context);
3 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 |
4 | module.exports = {
5 | devtool: "eval",
6 | entry: [
7 | "webpack-dev-server/client?http://localhost:3000",
8 | "webpack/hot/only-dev-server",
9 | path.resolve(__dirname, "scripts/index.js")
10 | ],
11 | output: {
12 | path: path.join(__dirname, "assets"),
13 | filename: "bundle.js",
14 | publicPath: "/assets/"
15 | },
16 | plugins: [
17 | new webpack.HotModuleReplacementPlugin(),
18 | new webpack.NoErrorsPlugin()
19 | ],
20 | resolve: {
21 | extensions: ["", ".js", ".jsx", ".less"]
22 | },
23 | module: {
24 | loaders: [
25 | { test: /\.jsx?$/, loaders: ["react-hot", "babel"], include: path.join(__dirname, "scripts") },
26 | { test: /\.less$/, loader: 'style!css!less' },
27 | // Font files
28 | { test: /\.woff2?(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff" },
29 | { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream" },
30 | { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file" },
31 | { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml" }
32 | ]
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/webpack.prod.config.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 |
4 | module.exports = {
5 | entry: {
6 | app: path.resolve(__dirname, "scripts/index.js"),
7 | vendors: ["react", "kinto", "moment", "uuid", "bootstrap/less/bootstrap.less"]
8 | },
9 | output: {
10 | path: path.join(__dirname, "assets"),
11 | filename: "bundle.js",
12 | publicPath: "assets/"
13 | },
14 | plugins: [
15 | new webpack.optimize.CommonsChunkPlugin('vendors', 'vendors.js')
16 | ],
17 | resolve: {
18 | extensions: ["", ".js", ".jsx", ".less"]
19 | },
20 | module: {
21 | loaders: [
22 | { test: /\.jsx?$/, loaders: ["babel"], include: path.join(__dirname, "scripts") },
23 | { test: /\.less$/, loader: 'style!css!less' },
24 | // Font files
25 | { test: /\.woff2?(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff" },
26 | { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream" },
27 | { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file" },
28 | { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml" }
29 | ]
30 | }
31 | };
32 |
--------------------------------------------------------------------------------