commit 3459e5b32a18c6d6c98c90a5166dee8096526ab2 Author: Mikko Ahlroth Date: Sun Dec 22 10:27:25 2019 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..86d2680 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 12.13.1 diff --git a/index.html b/index.html new file mode 100644 index 0000000..83ca2ba --- /dev/null +++ b/index.html @@ -0,0 +1,29 @@ + + + + + + Sodexo Hermia 5 & 6 Menu + + + + + +

Sodexo Hermia ruokalistat

+ +
+

Loading…

+

+ You need to have JavaScript turned on and a modern browser that supports ES2015 modules + to use this website. +

+

+ Sinulla pitää olla JavaScript päällä ja moderni selain joka tukee ES2015 moduuleja + jotta voit käyttää tätä verkkosivustoa. +

+
+ + + + + diff --git a/sodexo_menu.code-workspace b/sodexo_menu.code-workspace new file mode 100644 index 0000000..8f4183e --- /dev/null +++ b/sodexo_menu.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": "." + } + ] +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..b213e1c --- /dev/null +++ b/src/config.ts @@ -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; diff --git a/src/dom.ts b/src/dom.ts new file mode 100644 index 0000000..37e4bde --- /dev/null +++ b/src/dom.ts @@ -0,0 +1,36 @@ +interface DOMOptions { + id?: string; + classes?: string[]; + text?: string; +} + +interface DOMAttributes { + [key: string]: string; +} + +export function el(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; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a4b4330 --- /dev/null +++ b/src/index.ts @@ -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); +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..6951ecc --- /dev/null +++ b/src/parser.ts @@ -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}`) + } +} diff --git a/src/sodexo-api.ts b/src/sodexo-api.ts new file mode 100644 index 0000000..8b757a5 --- /dev/null +++ b/src/sodexo-api.ts @@ -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 { + 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 { + const promises = []; + + for (const restaurant of restaurants) { + promises.push(getMenu(restaurant)); + } + + return await Promise.all(promises); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..9bb6655 --- /dev/null +++ b/src/types.ts @@ -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[]; +} diff --git a/src/view.ts b/src/view.ts new file mode 100644 index 0000000..11473f3 --- /dev/null +++ b/src/view.ts @@ -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]; +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..334942b --- /dev/null +++ b/style.css @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..82b717c --- /dev/null +++ b/tsconfig.json @@ -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 + } +}