Initial commit
This commit is contained in:
commit
3459e5b32a
13 changed files with 513 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
dist/
|
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
@ -0,0 +1 @@
|
|||
nodejs 12.13.1
|
29
index.html
Normal file
29
index.html
Normal 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>
|
7
sodexo_menu.code-workspace
Normal file
7
sodexo_menu.code-workspace
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
23
src/config.ts
Normal file
23
src/config.ts
Normal 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
36
src/dom.ts
Normal 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
53
src/index.ts
Normal 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
53
src/parser.ts
Normal 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
28
src/sodexo-api.ts
Normal 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
56
src/types.ts
Normal 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
46
src/view.ts
Normal 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
116
style.css
Normal 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
64
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
Reference in a new issue