Initial commit

This commit is contained in:
Mikko Ahlroth 2019-12-22 10:27:25 +02:00
commit 3459e5b32a
13 changed files with 513 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
dist/

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
nodejs 12.13.1

29
index.html Normal file
View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="utf-8" />
<title>Sodexo Hermia 5 & 6 Menu</title>
<link rel="stylesheet" href="style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<h1>Sodexo Hermia ruokalistat</h1>
<main id="menu_el">
<p>Loading…</p>
<p>
You need to have JavaScript turned on and a modern browser that supports ES2015 modules
to use this website.
</p>
<p>
Sinulla pitää olla JavaScript päällä ja moderni selain joka tukee ES2015 moduuleja
jotta voit käyttää tätä verkkosivustoa.
</p>
</main>
<script type="module" src="dist/index.js" async></script>
</body>
</html>

View file

@ -0,0 +1,7 @@
{
"folders": [
{
"path": "."
}
]
}

23
src/config.ts Normal file
View file

@ -0,0 +1,23 @@
/** Restaurant IDs to fetch data for. */
export enum RestaurantId {
HERMIA5 = 107,
HERMIA6 = 110,
};
/** Define order of restaurants. */
export const RESTAURANTS: readonly RestaurantId[] = [
RestaurantId.HERMIA5,
RestaurantId.HERMIA6,
] as const;
/** ID of HTML tag to use in DOM for root element. */
export const HTML_ID = 'menu_el';
/** Names of days in the API. */
export const DAY_NAMES = [
'Maanantai',
'Tiistai',
'Keskivikko',
'Torstai',
'Perjantai'
] as const;

36
src/dom.ts Normal file
View file

@ -0,0 +1,36 @@
interface DOMOptions {
id?: string;
classes?: string[];
text?: string;
}
interface DOMAttributes {
[key: string]: string;
}
export function el<K extends keyof HTMLElementTagNameMap>(type: K, opts?: DOMOptions, attrs?: DOMAttributes): HTMLElementTagNameMap[K];
export function el(type: string, opts?: DOMOptions, attrs?: DOMAttributes): HTMLElement {
const elem = document.createElement(type);
if (opts !== undefined) {
if (opts.id) {
elem.id = opts.id;
}
if (opts.classes) {
for (const cls of opts.classes) { elem.classList.add(cls) }
}
if (opts.text) {
elem.textContent = opts.text;
}
}
if (attrs !== undefined) {
for (const [name, val] of Object.entries(attrs)) {
elem.setAttribute(name, val);
}
}
return elem;
}

53
src/index.ts Normal file
View file

@ -0,0 +1,53 @@
import { HTML_ID, RESTAURANTS, DAY_NAMES } from './config.js';
import { getAllMenus } from './sodexo-api.js';
import { parseBlob } from './parser.js';
import { renderMenu } from './view.js';
import { el } from './dom.js';
async function init() {
const rootEl = document.getElementById(HTML_ID);
if (rootEl === null) {
throw new Error('Cannot init DOM element.');
}
rootEl.innerText = 'Loading…';
const menus = await getAllMenus(RESTAURANTS);
const parsedMenus = menus.map((m, idx) => parseBlob(m, RESTAURANTS[idx]));
rootEl.innerText = '';
const dayElements: HTMLElement[] = [];
for (const day of DAY_NAMES) {
dayElements.push(
el('div', { classes: ['day-heading'], text: day })
);
}
for (const menu of parsedMenus) {
const [heading, ...elems] = renderMenu(menu);
rootEl.appendChild(heading);
for (const day of DAY_NAMES) {
rootEl.appendChild(
el(
'div',
{
classes: ['day-heading'],
text: day
},
{
'aria-hidden': 'true'
}
)
);
}
for (const elem of elems) { rootEl.appendChild(elem); }
}
}
if (document.readyState !== 'loading') {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}

53
src/parser.ts Normal file
View file

@ -0,0 +1,53 @@
import { ServerBlob, MenuData, MenuMeta, RestaurantInfo, DayData, Course, ServerMealdate } from './types.js';
import { RestaurantId } from './config.js';
export class ParseError extends Error { }
function parseMealdate(mealdate: ServerMealdate): DayData {
const coursekeys = Object.keys(mealdate.courses).sort();
const courses: Course[] = [];
for (const coursekey of coursekeys) {
const serverCourse = mealdate.courses[coursekey];
courses.push({
titleFi: serverCourse.title_fi,
titleEn: serverCourse.title_en,
category: serverCourse.category,
properties: serverCourse.properties,
price: serverCourse.price
});
}
return {
date: mealdate.date,
courses: courses
};
}
export function parseBlob(blob: ServerBlob, id: RestaurantId): MenuData {
try {
const meta: MenuMeta = {
generated: new Date(blob.meta.generated_timestamp * 1000),
timeperiod: blob.timeperiod
};
const restaurantInfo: RestaurantInfo = {
name: blob.meta.ref_title,
url: blob.meta.ref_url
};
const days: DayData[] = [];
for (const day of blob.mealdates) {
days.push(parseMealdate(day));
}
return {
id,
meta,
restaurantInfo,
days
};
} catch (e) {
console.error(e);
throw new ParseError(`Got error while parsing blob: ${e}\n\nBlob:\n${blob}`)
}
}

28
src/sodexo-api.ts Normal file
View file

@ -0,0 +1,28 @@
import { RestaurantId } from './config.js';
import { ServerBlob } from './types.js';
const API_URL = new URL('https://www.sodexo.fi/ruokalistat/output/weekly_json/');
export class SodexoAPIError extends Error { }
export async function getMenu(restaurantId: RestaurantId): Promise<ServerBlob> {
const url = new URL(String(restaurantId), API_URL);
const resp = await fetch(url.toString());
if (resp.ok) {
return await resp.json();
} else {
console.error(resp);
throw new SodexoAPIError(`Got invalid response: ${resp.status}.`);
}
}
export async function getAllMenus(restaurants: readonly RestaurantId[]): Promise<ServerBlob[]> {
const promises = [];
for (const restaurant of restaurants) {
promises.push(getMenu(restaurant));
}
return await Promise.all(promises);
}

56
src/types.ts Normal file
View file

@ -0,0 +1,56 @@
import { RestaurantId } from './config';
export interface ServerMeta {
readonly generated_timestamp: number;
readonly ref_url: string;
readonly ref_title: string;
}
export interface ServerCourse {
readonly title_fi: string;
readonly title_en: string;
readonly category: string;
readonly properties: string;
readonly price: string;
}
export interface ServerMealdate {
readonly date: string;
readonly courses: { [key: string]: ServerCourse };
}
export interface ServerBlob {
readonly meta: ServerMeta;
readonly timeperiod: string;
readonly mealdates: ServerMealdate[];
}
export interface RestaurantInfo {
readonly url: string;
readonly name: string;
}
export interface MenuMeta {
readonly generated: Date;
readonly timeperiod: string;
}
export interface Course {
readonly titleFi: string;
readonly titleEn: string;
readonly category: string;
readonly properties: string;
readonly price: string;
}
export interface DayData {
readonly date: string;
readonly courses: Course[];
}
export interface MenuData {
readonly id: RestaurantId;
readonly restaurantInfo: RestaurantInfo;
readonly meta: MenuMeta;
readonly days: DayData[];
}

46
src/view.ts Normal file
View file

@ -0,0 +1,46 @@
import { MenuData, Course } from './types.js';
import { el } from './dom.js';
function renderCourse(course: Course): HTMLLIElement {
const li = el('li');
const nameFi = el('p', { classes: ['course-name-fi'], text: course.titleFi }, { lang: 'fi' });
const nameEn = el('p', { classes: ['course-name-en'], text: course.titleEn }, { lang: 'en' });
const category = el('p', { classes: ['course-category'], text: course.category });
const properties = el('p', { classes: ['course-properties'], text: course.properties });
const price = el('p', { classes: ['course-price'], text: course.price });
li.appendChild(nameFi);
li.appendChild(nameEn);
li.appendChild(category);
li.appendChild(properties);
li.appendChild(price);
return li;
}
export function renderMenu(menu: MenuData): HTMLElement[] {
const heading = el('h2');
const headingLink = el(
'a',
{ text: `${menu.restaurantInfo.name}, ${menu.meta.timeperiod}` },
{ href: menu.restaurantInfo.url, target: '_blank' }
);
heading.appendChild(headingLink);
const dayElements = [];
for (const day of menu.days) {
const dayEl = el('div', { classes: ['day'] }, { 'data-day-name': day.date });
const coursesList = el('ul', {}, { 'aria-label': day.date });
for (const course of day.courses) {
coursesList.appendChild(renderCourse(course));
}
dayEl.appendChild(coursesList);
dayElements.push(dayEl);
}
const metaElem = el('p', {
classes: ['meta'],
text: `Päivitetty ${menu.meta.generated.toISOString()}.`
});
return [heading, ...dayElements, metaElem];
}

116
style.css Normal file
View file

@ -0,0 +1,116 @@
*, *::before {
box-sizing: border-box;
}
:root {
--background-color: #291F1E;
--lighter-bg: #343434;
--heading-color: #799DB4;
--text-color: #E9F0E2;
--alert-color: #F64740;
}
html {
background-color: var(--background-color);
color: var(--text-color);
font-family: "Helvetica", sans-serif;
font-size: 100%;
width: 100%;
padding: 0;
}
body {
width: 100%;
padding: 5px;
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 100;
color: var(--heading-color);
}
main {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
gap: 10px;
}
h2, .meta {
grid-column: span 5;
}
h2 a {
color: inherit;
}
h2 a:visited {
color: inherit;
}
.day-heading {
text-align: center;
font-weight: bold;
}
.day ul {
list-style-type: none;
margin: 0;
padding-left: 0;
}
.day ul li {
display: grid;
grid-template: 'cat cat' 'c-fi c-fi' 'c-en c-en' 'prop pric' / 1fr auto;
padding: 5px;
}
.day ul li:nth-child(2n + 1) {
background-color: var(--lighter-bg);
}
.day ul li p {
margin: 3px;
}
p.course-name-fi {
grid-area: c-fi;
hyphens: auto;
overflow-wrap: break-word;
}
p.course-name-en {
grid-area: c-en;
hyphens: auto;
overflow-wrap: break-word;
}
p.course-category {
grid-area: cat;
opacity: 0.5;
font-size: 80%;
}
p.course-properties {
grid-area: prop;
}
p.course-price {
grid-area: pric;
text-align: right;
}
.day[data-day-name="Maanantai"] {
grid-column: 1;
}
.day[data-day-name="Tiistai"] {
grid-column: 2;
}
.day[data-day-name="Keskiviikko"] {
grid-column: 3;
}
.day[data-day-name="Torstai"] {
grid-column: 4;
}
.day[data-day-name="Perjantai"] {
grid-column: 5;
}

64
tsconfig.json Normal file
View file

@ -0,0 +1,64 @@
{
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ES2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
"lib": [
"es2017",
"dom"
], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
"sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": false, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"noEmitHelpers": true
}
}