11 Ревизии 4ed8c874c6 ... 0d35ce35af

Автор SHA1 Съобщение Дата
  mightyplow 0d35ce35af implement item actions (delete, edit) преди 5 години
  mightyplow c0f41f872a implement base app structure with calorie input преди 5 години
  mightyplow 691d73688c remove make scripts for server преди 5 години
  mightyplow 2554b87949 remove css modules plugin for typescript, add classNames, use tslint преди 5 години
  mightyplow a32e573cca add typings to be included преди 5 години
  mightyplow 7bef85ab2d add deployment configs and helpers преди 5 години
  mightyplow 60f4822873 exchange eslint with tslint преди 5 години
  mightyplow 522f6e126d add dependencies and configs преди 5 години
  mightyplow 5b70276869 add compileOnSave option преди 5 години
  mightyplow 5de8c552bd add ts source files and add prebuild script for typescript checks преди 5 години
  mightyplow 28ff3723bf use ts unused vars преди 5 години
променени са 47 файла, в които са добавени 1170 реда и са изтрити 1096 реда
  1. 0 33
      .eslintrc.js
  2. 33 0
      Makefile
  3. 12 0
      docker-compose.yml
  4. 35 0
      nginx/nginx.conf
  5. 47 0
      nginx/simple-kalorie-tracker
  6. 259 1046
      package-lock.json
  7. 13 12
      package.json
  8. 6 0
      postcss.config.js
  9. 15 0
      src/assets/fonts/simpleCalorieTracker.svg
  10. BIN
      src/assets/fonts/simpleCalorieTracker.ttf
  11. BIN
      src/assets/fonts/simpleCalorieTracker.woff
  12. 5 3
      src/components/App.tsx
  13. 23 0
      src/components/button/IconButton.tsx
  14. 19 0
      src/components/button/button.css
  15. 68 0
      src/components/caloriesInput/CaloriesInput.tsx
  16. 35 0
      src/components/caloriesInput/caloriesInput.css
  17. 30 0
      src/components/caloriesList/CalorieRow.tsx
  18. 28 0
      src/components/caloriesList/CaloriesDateInput.tsx
  19. 90 0
      src/components/caloriesList/CaloriesList.tsx
  20. 18 0
      src/components/caloriesList/CaloriesSum.tsx
  21. 23 0
      src/components/caloriesList/ItemActions.tsx
  22. 18 0
      src/components/caloriesList/ListEditBox.tsx
  23. 21 0
      src/components/caloriesList/calorieRow.css
  24. 13 0
      src/components/caloriesList/caloriesDateInput.css
  25. 13 0
      src/components/caloriesList/caloriesList.css
  26. 3 0
      src/components/caloriesList/caloriesSum.css
  27. 11 0
      src/components/caloriesList/itemActions.css
  28. 4 0
      src/components/caloriesList/listEditBox.css
  29. 18 0
      src/components/dateDisplay/DateDisplay.tsx
  30. 7 0
      src/components/dateDisplay/dateDisplay.css
  31. 18 0
      src/components/icon/Icon.tsx
  32. 46 0
      src/components/icon/icon.css
  33. 1 1
      src/index.tsx
  34. 46 0
      src/layout/App.tsx
  35. 20 0
      src/layout/AppContext.ts
  36. 52 0
      src/layout/app.css
  37. 17 0
      src/layout/pageFooter/PageFooter.tsx
  38. 13 0
      src/layout/pageFooter/pageFooter.css
  39. 23 0
      src/layout/pageHeader/PageHeader.tsx
  40. 8 0
      src/layout/pageHeader/pageHeader.css
  41. 10 0
      src/styles/fonts.css
  42. 2 0
      src/styles/maps/icons.yml
  43. 4 0
      src/typings/cssModules.d.ts
  44. 9 0
      src/typings/models.d.ts
  45. 14 0
      src/utils/date.ts
  46. 6 1
      tsconfig.json
  47. 14 0
      tslint.json

+ 0 - 33
.eslintrc.js

@@ -1,33 +0,0 @@
-module.exports = {
-  env: {
-    browser: true,
-    es6: true,
-  },
-  extends: [
-    'standard',
-  ],
-  globals: {
-    Atomics: 'readonly',
-    SharedArrayBuffer: 'readonly',
-  },
-  parser: '@typescript-eslint/parser',
-  parserOptions: {
-    ecmaFeatures: {
-      jsx: true,
-    },
-    ecmaVersion: 2018,
-    sourceType: 'module',
-  },
-  plugins: [
-    'react',
-    '@typescript-eslint',
-  ],
-  rules: {
-    semi: ['error', 'always'],
-    'react/jsx-tag-spacing': ['error', {
-      beforeSelfClosing: 'always'
-    }],
-    'react/jsx-uses-react': 'error',
-    'react/jsx-uses-vars': 'error',
-  },
-};

+ 33 - 0
Makefile

@@ -0,0 +1,33 @@
+clientPath = './'
+targetPath = '/srv/www/simple-calorie-tracker'
+
+host = 'mightyserver'
+
+all: build
+
+setup:
+	npm i --prefix ${clientPath}
+
+build:
+	npm run build --prefix ${clientPath}
+
+ensure-target-directories:
+	ssh ${host} mkdir -p ${targetPath}
+
+deploy-web: ensure-target-directories
+	rsync -vzruc --delete-before -e ssh ./dist ${host}:${targetPath}/
+
+deploy-server: ensure-target-directories
+	rsync -vzruc --delete-before -e ssh ./{nginx, docker-compose.yml} ${host}:${targetPath}/
+	ssh ${host} mkdir -p ${targetPath}/log
+
+build-and-deploy: build deploy-server
+
+stop-server:
+	ssh ${host} docker-compose -f ${targetPath}/docker-compose.yml down
+
+start-server: deploy-server
+	ssh ${host} docker-compose -f ${targetPath}/docker-compose.yml up -d --build
+
+stop-and-clean:
+	ssh ${host} docker-compose -f ${targetPath}/docker-compose.yml down --rmi local --remove-orphans -v

+ 12 - 0
docker-compose.yml

@@ -0,0 +1,12 @@
+version: '3'
+
+services:
+  client:
+    image: nginx:alpine
+    restart: always
+    volumes:
+      - ./dist:/usr/share/nginx/html
+      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
+      - ./nginx/simple-kalorie-tracker:/etc/nginx/conf.d/default.conf
+    ports:
+      - '40180:80'

+ 35 - 0
nginx/nginx.conf

@@ -0,0 +1,35 @@
+
+user  nginx;
+worker_processes  1;
+
+error_log  /var/log/nginx/error.log warn;
+pid        /var/run/nginx.pid;
+
+
+events {
+    worker_connections  1024;
+}
+
+
+http {
+    include       /etc/nginx/mime.types;
+    default_type  application/octet-stream;
+
+    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
+                      '$status $body_bytes_sent "$http_referer" '
+                      '"$http_user_agent" "$http_x_forwarded_for"';
+
+    access_log  /var/log/nginx/access.log  main;
+
+    sendfile        on;
+    #tcp_nopush     on;
+
+    keepalive_timeout  65;
+
+    gzip  on;
+    gzip_comp_level 6;
+    gzip_vary on;
+    gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
+
+    include /etc/nginx/conf.d/*.conf;
+}

+ 47 - 0
nginx/simple-kalorie-tracker

@@ -0,0 +1,47 @@
+server {
+	root /usr/share/nginx/html;
+
+	# access_log /usr/share/nginx/logs/access.log combined;
+	# error_log /usr/share/nginx/logs/error.log error;
+
+	include mime.types;
+
+	# reuse port on redirects
+	absolute_redirect off;
+
+	try_files $uri $uri/ /index.html =404;
+
+    location = /index.html {
+        expires 0;
+        add_header Cache-Control 'no-cache';
+        break;
+    }
+
+    location = /sw.js {
+        expires 0;
+        add_header Cache-Control 'no-cache';
+        break;
+    }
+
+    location = /favicon.ico {
+        access_log off;
+        break;
+    }
+
+    location = /manifest.json {
+        access_log off;
+        break;
+    }
+
+    location /logs {
+        access_log off;
+        auth_basic 'Restricted';
+        auth_basic_user_file /etc/nginx/.htpasswd;
+    }
+
+    location ~ ^/(favicon\.png|fonts|js|img|css|app\.js|app\.css) {
+        # set cache header
+        expires 0;
+        break;
+    }
+}

Файловите разлики са ограничени, защото са твърде много
+ 259 - 1046
package-lock.json


+ 13 - 12
package.json

@@ -5,8 +5,9 @@
   "main": "index.js",
   "scripts": {
     "clean": "rm -rf ./dist",
-    "eslint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
-    "prebuild": "npm run clean",
+    "lint": "tslint --project .",
+    "tscheck": "tsc --noEmit",
+    "prebuild": "npm run clean && npm run tscheck",
     "build": "parcel build -d dist --public-url ./ ./src/index.html",
     "predev": "npm run clean",
     "dev": "parcel serve -d dist --public-url / ./src/index.html",
@@ -29,21 +30,21 @@
     "typescript": "^3.7.2"
   },
   "devDependencies": {
+    "@types/classnames": "^2.2.9",
     "@types/node": "^12.12.7",
     "@types/react": "^16.9.11",
     "@types/react-dom": "^16.9.4",
+    "@types/react-router": "^5.1.3",
+    "@types/react-router-dom": "^5.1.2",
     "@types/webpack-env": "^1.14.1",
-    "@typescript-eslint/eslint-plugin": "^2.7.0",
-    "@typescript-eslint/parser": "^2.7.0",
-    "eslint": "^6.6.0",
-    "eslint-config-standard": "^14.1.0",
-    "eslint-plugin-import": "^2.18.2",
-    "eslint-plugin-node": "^10.0.0",
-    "eslint-plugin-promise": "^4.2.1",
-    "eslint-plugin-react": "^7.16.0",
-    "eslint-plugin-standard": "^4.0.1",
+    "classnames": "^2.2.6",
     "parcel-bundler": "^1.12.4",
+    "postcss-modules": "^1.4.1",
+    "postcss-nested": "^4.2.1",
     "react": "^16.11.0",
-    "react-dom": "^16.11.0"
+    "react-dom": "^16.11.0",
+    "react-router": "^5.1.2",
+    "react-router-dom": "^5.1.2",
+    "tslint": "^5.20.1"
   }
 }

+ 6 - 0
postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  modules: true,
+  plugins: {
+    'postcss-nested': {}
+  }
+};

Файловите разлики са ограничени, защото са твърде много
+ 15 - 0
src/assets/fonts/simpleCalorieTracker.svg


BIN
src/assets/fonts/simpleCalorieTracker.ttf


BIN
src/assets/fonts/simpleCalorieTracker.woff


+ 5 - 3
src/components/App.tsx

@@ -1,11 +1,13 @@
 import React from 'react';
 
-function App () {
+function Button () {
   return (
-    <div>baz</div>
+    <div>
+
+    </div>
   );
 }
 
 export {
-  App
+  Button
 };

+ 23 - 0
src/components/button/IconButton.tsx

@@ -0,0 +1,23 @@
+import cx from 'classnames';
+import React, { ReactChild } from 'react';
+import { Icon } from '../icon/Icon';
+import styles from './button.css';
+
+type IconButtonProps = {
+  icon: string,
+  buttonClassName?: string,
+  iconClassName?: string,
+  onClick?: () => void
+};
+
+function IconButton ({icon, buttonClassName, iconClassName, onClick}: IconButtonProps) {
+  return (
+    <button className={cx(styles.iconButton, buttonClassName)} {...{onClick}}>
+      <Icon {...{icon}} className={cx(iconClassName)} />
+    </button >
+  );
+}
+
+export {
+  IconButton
+};

+ 19 - 0
src/components/button/button.css

@@ -0,0 +1,19 @@
+.iconButton {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    margin: 0;
+    padding: .2em;
+
+    font-size: 100%;
+    line-height: 1;
+
+    background: rgba(0, 0, 0, 0.1);
+    border: 0;
+    outline: none;
+    text-shadow: 1px 1px 1px #ddd;
+
+    &:hover {
+        background: rgba(0, 0, 0, 0.2);
+    }
+}

+ 68 - 0
src/components/caloriesInput/CaloriesInput.tsx

@@ -0,0 +1,68 @@
+import React, { FormEvent } from 'react';
+import { IconButton } from '../button/IconButton';
+import styles from './caloriesInput.css';
+
+type CaloriesInputProps = {
+  addCalories: (calories: { title: string, count: number}) => void,
+  saveCalories: (calories: { title: string, count: number}) => void,
+  item?: CalorieValue
+};
+
+interface ICalorieInputForm extends HTMLFormElement {
+  caloriesTitle: HTMLInputElement;
+  caloriesCount: HTMLInputElement;
+}
+
+function CaloriesInput ({ addCalories, saveCalories, item }: CaloriesInputProps) {
+  function onSubmit (event: FormEvent<ICalorieInputForm>) {
+    event.preventDefault();
+
+    const form = event.currentTarget;
+    const title = form.caloriesTitle.value;
+    const count = Number(form.caloriesCount.value) || 0;
+
+    if (!item) {
+      addCalories({ title, count });
+
+      form.querySelectorAll('input').forEach((formElement) => {
+        formElement.value = '';
+        formElement.blur();
+      });
+    } else {
+      saveCalories({ title, count});
+    }
+  }
+
+  const {
+    title: itemTitle = '',
+    count: itemCount = ''
+  } = item || {};
+
+  const buttonIcon = item
+    ? 'tick'
+    : 'plus';
+
+  return (
+    <form onSubmit={onSubmit} className={styles.caloriesInput}>
+      <div className={styles.inputWrapper}>
+        <div className={styles.formRow}>
+          <label htmlFor="caloriesTitle">Nahrungsmittel:</label>
+          <input id="caloriesTitle" name="caloriesTitle" type="text" defaultValue={itemTitle} required />
+        </div>
+
+        <div className={styles.formRow}>
+          <label htmlFor="caloriesCount">Kalorien:</label>
+          <input id="caloriesCount" name="caloriesCount" type="number" defaultValue={itemCount} required />
+        </div>
+      </div>
+
+      <div className={styles.buttonWrapper}>
+        <IconButton icon={buttonIcon} buttonClassName={styles.addButton} />
+      </div>
+    </form>
+  );
+}
+
+export {
+  CaloriesInput
+};

+ 35 - 0
src/components/caloriesInput/caloriesInput.css

@@ -0,0 +1,35 @@
+.caloriesInput {
+    display: flex;
+}
+
+.formRow {
+    display: flex;
+    align-items: center;
+
+    &:not(:last-child) {
+        margin-bottom: .5em;
+    }
+
+    label {
+        margin-right: 1em;
+        width: 7em;
+    }
+
+    input {
+        flex: 1;
+        width: 0;
+    }
+}
+
+.inputWrapper {
+    width: 100%;
+    margin-right: 1em;
+}
+
+.buttonWrapper {
+    display: flex;
+}
+
+.addButton {
+    padding: 1em;
+}

+ 30 - 0
src/components/caloriesList/CalorieRow.tsx

@@ -0,0 +1,30 @@
+import cx from 'classnames';
+import React, { MouseEventHandler } from 'react';
+import styles from './calorieRow.css';
+
+type CalorieRowProps = {
+  item: CalorieValue,
+  onRowClick?: (item: CalorieValue) => void,
+  isSelected?: boolean
+};
+
+function CalorieRow (props: CalorieRowProps) {
+  const {
+    item,
+    isSelected = false,
+    onRowClick = Function.prototype
+  } = props;
+
+  const onClick = () => onRowClick(item);
+
+  return (
+    <div className={cx(styles.calorieRow, {[styles.selected]: isSelected})} {...{onClick}}>
+      <div className={styles.title}>{item.title}</div>
+      <div>{item.count}</div>
+    </div>
+  );
+}
+
+export {
+  CalorieRow
+};

+ 28 - 0
src/components/caloriesList/CaloriesDateInput.tsx

@@ -0,0 +1,28 @@
+import React, { ChangeEvent, useContext } from 'react';
+import { AppContext } from '../../layout/AppContext';
+import { getDateString } from '../../utils/date';
+import styles from './caloriesDateInput.css';
+
+function CaloriesDateInput () {
+  const {selectedDate, setSelectedDate} = useContext(AppContext);
+
+  function onDateChange (event: ChangeEvent<HTMLInputElement>) {
+    const newDate = (new Date(event.target.value)).getTime();
+    setSelectedDate(newDate);
+  }
+
+  const date = new Date(selectedDate);
+  const selectedDateString = getDateString(date);
+
+  return (
+    <div>
+      <span>{date.toLocaleDateString(navigator.language, { weekday: 'short'})}, </span>
+      <input required type="date" value={selectedDateString} onChange={onDateChange}
+             className={styles.caloriesDateInput} />
+    </div>
+  );
+}
+
+export {
+  CaloriesDateInput
+};

+ 90 - 0
src/components/caloriesList/CaloriesList.tsx

@@ -0,0 +1,90 @@
+import React, { useContext, useState } from 'react';
+import { AppContext } from '../../layout/AppContext';
+import { haveSameDay } from '../../utils/date';
+import { CaloriesInput } from '../caloriesInput/CaloriesInput';
+import { CalorieRow } from './CalorieRow';
+import styles from './caloriesList.css';
+import { ItemActions } from './ItemActions';
+import { ListEditBox } from './ListEditBox';
+
+function CaloriesList () {
+  const {calorieItems = [], setCalorieItems, selectedDate} = useContext(AppContext);
+  const [selectedItem, setSelectedItem] = useState<CalorieValue>();
+
+  function addCalories (addedCalories: { title: string, count: number }) {
+    const updatedCalories = [
+      ...calorieItems,
+      {
+        ...addedCalories,
+        timestamp: selectedDate
+      }
+    ];
+
+    setCalorieItems(updatedCalories);
+  }
+
+  function removeItem () {
+    setCalorieItems(calorieItems.filter((item) => item !== selectedItem));
+  }
+
+  function saveCalories (changedCalories: { title: string, count: number }) {
+    if (!selectedItem) {
+      return;
+    }
+
+    const itemIndex = calorieItems.indexOf(selectedItem);
+    const updatedItem = {
+      ...selectedItem,
+      ...changedCalories
+    };
+
+    const updatedItems =  [
+      ...calorieItems.slice(0, itemIndex),
+      updatedItem,
+      ...calorieItems.slice(itemIndex + 1)
+    ];
+
+    setCalorieItems(updatedItems);
+    setSelectedItem(updatedItem);
+  }
+
+  function onRowClick (item: CalorieValue) {
+    if (selectedItem === item) {
+      return setSelectedItem(undefined);
+    }
+
+    return setSelectedItem(item);
+  }
+
+  const calories = calorieItems.filter(({timestamp}) => haveSameDay(timestamp, selectedDate));
+
+  return (
+    <section className={styles.caloriesList}>
+      <section className={styles.caloriesItems}>
+        {calories.map((item: CalorieValue, index: number) => {
+          // todo: use better key
+          const isSelected = item === selectedItem;
+          return (
+            <CalorieRow key={index} {...{item, onRowClick, isSelected}} />
+          );
+        })}
+      </section>
+
+      <div className={styles.editBoxes}>
+        {selectedItem &&
+        <ListEditBox>
+          <ItemActions {...{removeItem}} />
+        </ListEditBox>
+        }
+
+        <ListEditBox>
+          <CaloriesInput {...{addCalories, saveCalories, item: selectedItem}} />
+        </ListEditBox>
+      </div>
+    </section>
+  );
+}
+
+export {
+  CaloriesList
+};

+ 18 - 0
src/components/caloriesList/CaloriesSum.tsx

@@ -0,0 +1,18 @@
+import React from 'react';
+import styles from './caloriesSum.css';
+
+type CaloriesSumProps = {
+  calories: CalorieValue[]
+};
+
+function CaloriesSum ({ calories }: CaloriesSumProps) {
+  return (
+    <section className={styles.caloriesSum}>
+      {calories.reduce((sum, { count }) => sum + count, 0)}
+    </section>
+  );
+}
+
+export {
+  CaloriesSum
+};

+ 23 - 0
src/components/caloriesList/ItemActions.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+import { IconButton } from '../button/IconButton';
+import styles from './itemActions.css';
+
+type ItemActionsProps = {
+  removeItem: () => void;
+};
+
+function ItemActions (props: ItemActionsProps) {
+  const {
+    removeItem
+  } = props;
+
+  return (
+    <section className={styles.itemActions}>
+      <IconButton icon="trash" buttonClassName={styles.button} onClick={removeItem} />
+    </section>
+  );
+}
+
+export {
+  ItemActions
+};

+ 18 - 0
src/components/caloriesList/ListEditBox.tsx

@@ -0,0 +1,18 @@
+import React, { ReactNode } from 'react';
+import styles from './listEditBox.css';
+
+type ListEditBoxProps = {
+  children: ReactNode
+};
+
+function ListEditBox ({children}: ListEditBoxProps) {
+  return (
+    <div className={styles.listEditBox}>
+      {children}
+    </div>
+  );
+}
+
+export {
+  ListEditBox
+};

+ 21 - 0
src/components/caloriesList/calorieRow.css

@@ -0,0 +1,21 @@
+.calorieRow {
+    display: flex;
+    justify-content: space-between;
+    padding: .5em .5em;
+
+    &:nth-child(2n) {
+        background: #f5f5f5;
+    }
+
+    &.selected {
+        background: #ddd;
+    }
+}
+
+.title {
+    margin-right: 1em;
+}
+
+.count {
+
+}

+ 13 - 0
src/components/caloriesList/caloriesDateInput.css

@@ -0,0 +1,13 @@
+.caloriesDateInput {
+    width: auto;
+    background: none;
+    border: 0;
+    outline: none;
+    font-size: 100%;
+    font-family: inherit;
+}
+
+::-webkit-datetime-edit {
+    /* used to decrease space to triangle */
+    max-width: fit-content;
+}

+ 13 - 0
src/components/caloriesList/caloriesList.css

@@ -0,0 +1,13 @@
+.caloriesList {
+    display: flex;
+    flex-flow: column nowrap;
+    height: 100%;
+}
+
+.caloriesItems {
+    overflow: auto;
+}
+
+.editBoxes {
+    margin-top: auto;
+}

+ 3 - 0
src/components/caloriesList/caloriesSum.css

@@ -0,0 +1,3 @@
+.caloriesSum {
+    text-align: right;
+}

+ 11 - 0
src/components/caloriesList/itemActions.css

@@ -0,0 +1,11 @@
+.itemActions {
+    display: flex;
+    justify-content: flex-end;
+    font-size: 2em;
+
+    .button {
+        &:not(:first-child) {
+            margin-left: .3em;
+        }
+    }
+}

+ 4 - 0
src/components/caloriesList/listEditBox.css

@@ -0,0 +1,4 @@
+.listEditBox {
+    border-top: 1px solid #ccc;
+    padding: .5em;
+}

+ 18 - 0
src/components/dateDisplay/DateDisplay.tsx

@@ -0,0 +1,18 @@
+import React, { useRef } from 'react';
+import styles from './dateDisplay.css';
+
+type DateDisplayProps = {
+  date: Date
+};
+
+function DateDisplay ({ date }: DateDisplayProps) {
+  return (
+    <section>
+      <input type="date" className={styles.dateInput} />
+    </section>
+  );
+}
+
+export {
+  DateDisplay
+};

+ 7 - 0
src/components/dateDisplay/dateDisplay.css

@@ -0,0 +1,7 @@
+.dateDisplay {
+
+}
+
+.dateInput {
+    display: none;
+}

+ 18 - 0
src/components/icon/Icon.tsx

@@ -0,0 +1,18 @@
+import cx from 'classnames';
+import React from 'react';
+import styles from './icon.css';
+
+type IconProps = {
+  icon: string,
+  className?: string
+};
+
+function Icon ({ icon, className }: IconProps) {
+  return (
+    <div className={cx(styles.icon, styles[`icon-${icon}`], className)} />
+  );
+}
+
+export {
+  Icon
+};

+ 46 - 0
src/components/icon/icon.css

@@ -0,0 +1,46 @@
+:root {
+    --icon-settings: '\e900';
+    --icon-plus: '\e901';
+    --icon-trash: '\e902';
+    --icon-edit: '\e903';
+    --icon-tick: '\e904';
+}
+
+.icon {
+    display: inline-flex;
+    align-items: center;
+
+    &:after {
+        font-family: simpleCalorieTracker;
+    }
+
+    &.icon-settings {
+        &:after {
+            content: var(--icon-settings);
+        }
+    }
+
+    &.icon-plus {
+        &:after {
+            content: var(--icon-plus);
+        }
+    }
+
+    &.icon-edit {
+        &:after {
+            content: var(--icon-edit);
+        }
+    }
+
+    &.icon-trash {
+        &:after {
+            content: var(--icon-trash);
+        }
+    }
+
+    &.icon-tick {
+        &:after {
+            content: var(--icon-tick);
+        }
+    }
+}

+ 1 - 1
src/index.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { render } from 'react-dom';
-import { App } from './components/App';
+import { App } from './layout/App';
 
 if (process.env.NODE_ENV === 'development' && module.hot) {
   module.hot.accept();

+ 46 - 0
src/layout/App.tsx

@@ -0,0 +1,46 @@
+import React, { ReactElement, useEffect, useState } from 'react';
+import {
+  BrowserRouter as Router,
+  Route,
+  Switch
+} from 'react-router-dom';
+import { CaloriesList } from '../components/caloriesList/CaloriesList';
+import { AppContext } from './AppContext';
+import { PageFooter } from './pageFooter/PageFooter';
+import { PageHeader } from './pageHeader/PageHeader';
+
+import './app.css';
+
+const AppContextProvider = AppContext.Provider;
+const storedCalories = JSON.parse(localStorage.getItem('calorieItems') || '[]');
+
+function App (): ReactElement {
+  const [calorieItems, setCalorieItems] = useState<CalorieItems>(storedCalories);
+  const [selectedDate, setSelectedDate] = useState<number>(Date.now());
+
+  useEffect(() => {
+    localStorage.setItem('calorieItems', JSON.stringify(calorieItems));
+  });
+
+  return (
+    <AppContextProvider value={{calorieItems, setCalorieItems, selectedDate, setSelectedDate}}>
+      <PageHeader />
+
+      <main id="pageContent">
+        <Router>
+          <Switch>
+            <Route path="/">
+              <CaloriesList />
+            </Route>
+          </Switch>
+        </Router>
+      </main>
+
+      <PageFooter />
+    </AppContextProvider>
+  );
+}
+
+export {
+  App
+};

+ 20 - 0
src/layout/AppContext.ts

@@ -0,0 +1,20 @@
+import { createContext } from "react";
+import { getDateString } from '../utils/date';
+
+type AppContextType = {
+  selectedDate: number,
+  setSelectedDate: (selectedDate: number) => void,
+  calorieItems: CalorieItems;
+  setCalorieItems: (calorieItems: CalorieValue[]) => void;
+};
+
+const AppContext = createContext<AppContextType>({
+  calorieItems: [],
+  selectedDate: Date.now(),
+  setCalorieItems: () => [],
+  setSelectedDate: () => getDateString(new Date())
+});
+
+export {
+  AppContext
+};

+ 52 - 0
src/layout/app.css

@@ -0,0 +1,52 @@
+:global {
+    @import "../styles/fonts.css";
+
+    :root {
+        --primaryColor: #4dbfff;
+    }
+
+    ::-webkit-clear-button {
+        display: none;
+    }
+
+    html, body, #appContainer {
+        width: 100%;
+        height: 100%;
+    }
+
+    body, p, ul {
+        margin: 0;
+    }
+
+    body {
+        font-family: sans-serif;
+    }
+
+    #appContainer {
+        display: grid;
+        grid-template-columns: 100%;
+        grid-template-rows: min-content auto min-content;
+        grid-template-areas:
+            'header'
+            'main'
+            'footer';
+    }
+
+    #pageHeader {
+        @media (min-width: 640px) {
+            grid-area: footer;
+        }
+    }
+
+    #pageContent {
+        grid-area: main;
+        align-items: start;
+        overflow: hidden;
+    }
+
+    #pageFooter {
+        @media (min-width: 640px) {
+            grid-area: header;
+        }
+    }
+}

+ 17 - 0
src/layout/pageFooter/PageFooter.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { IconButton } from '../../components/button/IconButton';
+import styles from './pageFooter.css';
+
+function PageFooter () {
+  return (
+    <footer id="pageFooter" className={styles.pageFooter}>
+      <section className={styles.buttons}>
+        <IconButton icon="settings" />
+      </section>
+    </footer>
+  );
+}
+
+export {
+  PageFooter
+};

+ 13 - 0
src/layout/pageFooter/pageFooter.css

@@ -0,0 +1,13 @@
+.pageFooter {
+    display: flex;
+    padding: .5em;
+    background: var(--primaryColor);
+}
+
+.buttons {
+    display: flex;
+    align-items: center;
+    margin-left: auto;
+    font-size: 1.7em;
+    line-height: 1;
+}

+ 23 - 0
src/layout/pageHeader/PageHeader.tsx

@@ -0,0 +1,23 @@
+import React, { useContext } from 'react';
+import { CaloriesDateInput } from '../../components/caloriesList/CaloriesDateInput';
+import { CaloriesSum } from '../../components/caloriesList/CaloriesSum';
+import { haveSameDay } from '../../utils/date';
+import { AppContext } from '../AppContext';
+import styles from './pageHeader.css';
+
+function PageHeader () {
+  const {calorieItems, selectedDate} = useContext(AppContext);
+
+  const calories = calorieItems.filter(({timestamp}) => haveSameDay(timestamp, selectedDate));
+
+  return (
+    <header id="pageHeader" className={styles.pageHeader}>
+      <CaloriesDateInput />
+      <CaloriesSum {...{calories}} />
+    </header>
+  );
+}
+
+export {
+  PageHeader
+};

+ 8 - 0
src/layout/pageHeader/pageHeader.css

@@ -0,0 +1,8 @@
+.pageHeader {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: .5em;
+
+    background: var(--primaryColor);
+}

+ 10 - 0
src/styles/fonts.css

@@ -0,0 +1,10 @@
+@font-face {
+    font-family: 'simpleCalorieTracker';
+    src:
+            url('../assets/fonts/simpleCalorieTracker.ttf?8t3p8t') format('truetype'),
+            url('../assets/fonts/simpleCalorieTracker.woff?8t3p8t') format('woff'),
+            url('../assets/fonts/simpleCalorieTracker.svg?8t3p8t#simpleCalorieTracker') format('svg');
+    font-weight: normal;
+    font-style: normal;
+    font-display: block;
+}

+ 2 - 0
src/styles/maps/icons.yml

@@ -0,0 +1,2 @@
+settings: '\e900'
+plus: '\e901'

+ 4 - 0
src/typings/cssModules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.css' {
+  const styles: { [className: string]: string };
+  export default styles;
+}

+ 9 - 0
src/typings/models.d.ts

@@ -0,0 +1,9 @@
+type CalorieItems = CalorieValue[];
+
+type CalorieValue = {
+  title: string,
+  count: number,
+  timestamp: number
+};
+
+type CalorieValueSetter = (calories: CalorieItems) => void;

+ 14 - 0
src/utils/date.ts

@@ -0,0 +1,14 @@
+export function getDateString (date: Date) {
+  const year = date.getFullYear();
+  const month = date.getMonth() + 1;
+  const day = date.getDate();
+  return [year, month, day]
+    .map(String)
+    .map((part) => part.padStart(2, '0'))
+    .join('-');
+}
+
+export function haveSameDay (timestampA: number, timestampB: number) {
+  const dayInMs = 3600 * 24 * 1000;
+  return Math.floor(timestampA / dayInMs) === Math.floor(timestampB / dayInMs);
+}

+ 6 - 1
tsconfig.json

@@ -1,4 +1,5 @@
 {
+  "compileOnSave": true,
   "compilerOptions": {
     /* Basic Options */
     // "incremental": true,                   /* Enable incremental compilation */
@@ -62,5 +63,9 @@
 
     /* Advanced Options */
     "forceConsistentCasingInFileNames": true  /* Disallow inconsistently-cased references to the same file. */
-  }
+  },
+  "include": [
+    "src/**/*",
+    "src/typings"
+  ]
 }

+ 14 - 0
tslint.json

@@ -0,0 +1,14 @@
+{
+    "defaultSeverity": "error",
+    "extends": [
+        "tslint:recommended"
+    ],
+    "jsRules": {},
+    "rules": {
+        "interface-over-type-literal": false,
+        "trailing-comma": false,
+        "quotemark": false,
+        "space-before-function-paren": false
+    },
+    "rulesDirectory": []
+}