diff --git a/components/ForecastList.js b/components/ForecastList.js new file mode 100644 index 000000000..31c4e60d8 --- /dev/null +++ b/components/ForecastList.js @@ -0,0 +1,13 @@ +// UPDATED: Passes weather condition to WeatherCard +import { WeatherCard } from './WeatherCard.js'; + +export const ForecastList = (weatherData) => { + return ` +
+

Weekly Forecast

+
+ ${weatherData.map((temp, index) => WeatherCard(index + 1, temp, "sunny")).join('')} +
+
+ `; +} diff --git a/components/HourlyForecast.js b/components/HourlyForecast.js new file mode 100644 index 000000000..2d78c23f3 --- /dev/null +++ b/components/HourlyForecast.js @@ -0,0 +1,20 @@ +import { WeatherCard } from './WeatherCard.js'; + +export function HourlyForecast(hourlyData, weatherCodes) { + return ` +
+

24-Hour Forecast

+
+ ${hourlyData.map((temp, index) => WeatherCard(index, temp, getWeatherCondition(weatherCodes[index]))).join('')} +
+
+ `; +} + +function getWeatherCondition(code) { + if (code === 1) return "sunny"; + if (code === 2) return "cloudy"; + if (code === 3) return "rainy"; + if (code === 4) return "snowy"; + return "cloudy"; // Default +} diff --git a/components/SearchBar.js b/components/SearchBar.js new file mode 100644 index 000000000..30f154bfd --- /dev/null +++ b/components/SearchBar.js @@ -0,0 +1,9 @@ +// NEW FILE: Search bar component for user input +export const SearchBar = () => { + return ` +
+ + +
+ `; +} diff --git a/components/WeatherCard.js b/components/WeatherCard.js new file mode 100644 index 000000000..6bb4ca91a --- /dev/null +++ b/components/WeatherCard.js @@ -0,0 +1,34 @@ +export const WeatherCard = (hourIndex, temperature, condition) => { + const icon = getWeatherIcon(condition); + return ` +
+

${formatHour(hourIndex)}

+ ${condition} +

${temperature !== undefined ? temperature.toFixed(1) : "N/A"}°C

+
+ `; +}; + +// Function to format hours correctly (00:00 to 23:00) +const formatHour = (hourIndex) => { + return `${hourIndex.toString().padStart(2, '0')}:00`; +}; + +// Function to get weather icons based on conditions +const getWeatherIcon = (condition) => { + if (condition.includes("sunny")) return "../public/sunny.png"; +if (condition.includes("cloudy")) return "../public/cloudy.png"; +if (condition.includes("rain")) return "../public/rainy.jpg"; +if (condition.includes("snow")) return "../public/snow.webp"; +return "cloudy.png"; // Default icon +}; + + + + +/*if (condition.includes("sunny")) return "../public/sunny.png"; +if (condition.includes("cloudy")) return "../public/cloudy.png"; +if (condition.includes("rain")) return "../public/rainy.jpg"; +if (condition.includes("snow")) return "../public/snow.webp"; +return "cloudy.png"; // Default icon +};*/ diff --git a/components/WeeklyForecast.js b/components/WeeklyForecast.js new file mode 100644 index 000000000..cec6f4eb4 --- /dev/null +++ b/components/WeeklyForecast.js @@ -0,0 +1,19 @@ +import { WeatherCard } from './WeatherCard.js'; + +export function WeeklyForecast(weeklyData) { + const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", "Next Monday", "Next Tuesday", "Next Wednesday"]; + + return ` +
+

10-Day Forecast

+
+ ${weeklyData.map((temp, index) => ` +
+

${days[index]}

+

${temp !== undefined ? temp.toFixed(1) : "N/A"}°C

+
+ `).join('')} +
+
+ `; +} diff --git a/data/weatherData.js b/data/weatherData.js new file mode 100644 index 000000000..bcb987170 --- /dev/null +++ b/data/weatherData.js @@ -0,0 +1,16 @@ +export async function getWeatherData(location) { + const API_URL = `https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}&hourly=temperature_2m,weathercode&daily=temperature_2m_max,temperature_2m_min,weathercode&forecast_days=10&timezone=auto`; + + try { + const response = await fetch(API_URL); + const data = await response.json(); + return { + hourly: data.hourly.temperature_2m.slice(0, 24), // 24-hour forecast + weekly: data.daily.temperature_2m_max.slice(0, 10), // 10-day forecast + weatherCodes: data.hourly.weathercode.slice(0, 24) // Weather conditions + }; + } catch (error) { + console.error("Error fetching weather data:", error); + return { hourly: [], weekly: [], weatherCodes: [] }; + } +} diff --git a/index.html b/index.html new file mode 100644 index 000000000..39d1724cc --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + Weather App + + + +
+ + +
+
+ + + diff --git a/public/cloudy.png b/public/cloudy.png new file mode 100644 index 000000000..9ea1d952d Binary files /dev/null and b/public/cloudy.png differ diff --git a/public/rainy.jpg b/public/rainy.jpg new file mode 100644 index 000000000..cd34c3c1e Binary files /dev/null and b/public/rainy.jpg differ diff --git a/public/snow.webp b/public/snow.webp new file mode 100644 index 000000000..1699c1c56 Binary files /dev/null and b/public/snow.webp differ diff --git a/public/sunny.png b/public/sunny.png new file mode 100644 index 000000000..1390b8e43 Binary files /dev/null and b/public/sunny.png differ diff --git a/src/app.test.ts b/src/app.test.ts deleted file mode 100644 index 7faf530a6..000000000 --- a/src/app.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import assert from 'node:assert/strict' -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' -import test from 'node:test' - -import getPort from 'get-port' -import { Low, Memory } from 'lowdb' -import { temporaryDirectory } from 'tempy' - -import { createApp } from './app.js' -import { Data } from './service.js' - -type Test = { - - method: HTTPMethods - url: string - statusCode: number -} - -type HTTPMethods = - | 'DELETE' - | 'GET' - | 'HEAD' - | 'PATCH' - | 'POST' - | 'PUT' - | 'OPTIONS' - -const port = await getPort() - -// Create custom static dir with an html file -const tmpDir = temporaryDirectory() -const file = 'file.html' -writeFileSync(join(tmpDir, file), 'utf-8') - -// Create app -const db = new Low(new Memory(), {}) -db.data = { - posts: [{ id: '1', title: 'foo' }], - comments: [{ id: '1', postId: '1' }], - object: { f1: 'foo' }, -} -const app = createApp(db, { static: [tmpDir] }) - -await new Promise((resolve, reject) => { - try { - const server = app.listen(port, () => resolve()) - test.after(() => server.close()) - } catch (err) { - reject(err) - } -}) - -await test('createApp', async (t) => { - // URLs - const POSTS = '/posts' - const POSTS_WITH_COMMENTS = '/posts?_embed=comments' - const POST_1 = '/posts/1' - const POST_NOT_FOUND = '/posts/-1' - const POST_WITH_COMMENTS = '/posts/1?_embed=comments' - const COMMENTS = '/comments' - const POST_COMMENTS = '/comments?postId=1' - const NOT_FOUND = '/not-found' - const OBJECT = '/object' - const OBJECT_1 = '/object/1' - - const arr: Test[] = [ - // Static - { method: 'GET', url: '/', statusCode: 200 }, - { method: 'GET', url: '/test.html', statusCode: 200 }, - { method: 'GET', url: `/${file}`, statusCode: 200 }, - - // CORS - { method: 'OPTIONS', url: POSTS, statusCode: 204 }, - - // API - { method: 'GET', url: POSTS, statusCode: 200 }, - { method: 'GET', url: POSTS_WITH_COMMENTS, statusCode: 200 }, - { method: 'GET', url: POST_1, statusCode: 200 }, - { method: 'GET', url: POST_NOT_FOUND, statusCode: 404 }, - { method: 'GET', url: POST_WITH_COMMENTS, statusCode: 200 }, - { method: 'GET', url: COMMENTS, statusCode: 200 }, - { method: 'GET', url: POST_COMMENTS, statusCode: 200 }, - { method: 'GET', url: OBJECT, statusCode: 200 }, - { method: 'GET', url: OBJECT_1, statusCode: 404 }, - { method: 'GET', url: NOT_FOUND, statusCode: 404 }, - - { method: 'POST', url: POSTS, statusCode: 201 }, - { method: 'POST', url: POST_1, statusCode: 404 }, - { method: 'POST', url: POST_NOT_FOUND, statusCode: 404 }, - { method: 'POST', url: OBJECT, statusCode: 404 }, - { method: 'POST', url: OBJECT_1, statusCode: 404 }, - { method: 'POST', url: NOT_FOUND, statusCode: 404 }, - - { method: 'PUT', url: POSTS, statusCode: 404 }, - { method: 'PUT', url: POST_1, statusCode: 200 }, - { method: 'PUT', url: OBJECT, statusCode: 200 }, - { method: 'PUT', url: OBJECT_1, statusCode: 404 }, - { method: 'PUT', url: POST_NOT_FOUND, statusCode: 404 }, - { method: 'PUT', url: NOT_FOUND, statusCode: 404 }, - - { method: 'PATCH', url: POSTS, statusCode: 404 }, - { method: 'PATCH', url: POST_1, statusCode: 200 }, - { method: 'PATCH', url: OBJECT, statusCode: 200 }, - { method: 'PATCH', url: OBJECT_1, statusCode: 404 }, - { method: 'PATCH', url: POST_NOT_FOUND, statusCode: 404 }, - { method: 'PATCH', url: NOT_FOUND, statusCode: 404 }, - - { method: 'DELETE', url: POSTS, statusCode: 404 }, - { method: 'DELETE', url: POST_1, statusCode: 200 }, - { method: 'DELETE', url: OBJECT, statusCode: 404 }, - { method: 'DELETE', url: OBJECT_1, statusCode: 404 }, - { method: 'DELETE', url: POST_NOT_FOUND, statusCode: 404 }, - { method: 'DELETE', url: NOT_FOUND, statusCode: 404 }, - ] - - for (const tc of arr) { - await t.test(`${tc.method} ${tc.url}`, async () => { - const response = await fetch(`http://localhost:${port}${tc.url}`, { - method: tc.method, - }) - assert.equal( - response.status, - tc.statusCode, - `${response.status} !== ${tc.statusCode} ${tc.method} ${tc.url} failed`, - ) - }) - } -}) diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index b8c5e79e7..000000000 --- a/src/app.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { dirname, isAbsolute, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { App } from '@tinyhttp/app' -import { cors } from '@tinyhttp/cors' -import { Eta } from 'eta' -import { Low } from 'lowdb' -import { json } from 'milliparsec' -import sirv from 'sirv' - -import { Data, isItem, Service } from './service.js' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const isProduction = process.env['NODE_ENV'] === 'production' - -export type AppOptions = { - logger?: boolean - static?: string[] -} - -const eta = new Eta({ - views: join(__dirname, '../views'), - cache: isProduction, -}) - -export function createApp(db: Low, options: AppOptions = {}) { - // Create service - const service = new Service(db) - - // Create app - const app = new App() - - // Static files - app.use(sirv('public', { dev: !isProduction })) - options.static - ?.map((path) => (isAbsolute(path) ? path : join(process.cwd(), path))) - .forEach((dir) => app.use(sirv(dir, { dev: !isProduction }))) - - // CORS - app - .use((req, res, next) => { - return cors({ - allowedHeaders: req.headers['access-control-request-headers'] - ?.split(',') - .map((h) => h.trim()), - })(req, res, next) - }) - .options('*', cors()) - - // Body parser - // @ts-expect-error expected - app.use(json()) - - app.get('/', (_req, res) => - res.send(eta.render('index.html', { data: db.data })), - ) - - app.get('/:name', (req, res, next) => { - const { name = '' } = req.params - const query = Object.fromEntries( - Object.entries(req.query) - .map(([key, value]) => { - if ( - ['_start', '_end', '_limit', '_page', '_per_page'].includes(key) && - typeof value === 'string' - ) { - return [key, parseInt(value)] - } else { - return [key, value] - } - }) - .filter(([, value]) => !Number.isNaN(value)), - ) - res.locals['data'] = service.find(name, query) - next?.() - }) - - app.get('/:name/:id', (req, res, next) => { - const { name = '', id = '' } = req.params - res.locals['data'] = service.findById(name, id, req.query) - next?.() - }) - - app.post('/:name', async (req, res, next) => { - const { name = '' } = req.params - if (isItem(req.body)) { - res.locals['data'] = await service.create(name, req.body) - } - next?.() - }) - - app.put('/:name', async (req, res, next) => { - const { name = '' } = req.params - if (isItem(req.body)) { - res.locals['data'] = await service.update(name, req.body) - } - next?.() - }) - - app.put('/:name/:id', async (req, res, next) => { - const { name = '', id = '' } = req.params - if (isItem(req.body)) { - res.locals['data'] = await service.updateById(name, id, req.body) - } - next?.() - }) - - app.patch('/:name', async (req, res, next) => { - const { name = '' } = req.params - if (isItem(req.body)) { - res.locals['data'] = await service.patch(name, req.body) - } - next?.() - }) - - app.patch('/:name/:id', async (req, res, next) => { - const { name = '', id = '' } = req.params - if (isItem(req.body)) { - res.locals['data'] = await service.patchById(name, id, req.body) - } - next?.() - }) - - app.delete('/:name/:id', async (req, res, next) => { - const { name = '', id = '' } = req.params - res.locals['data'] = await service.destroyById( - name, - id, - req.query['_dependent'], - ) - next?.() - }) - - app.use('/:name', (req, res) => { - const { data } = res.locals - if (data === undefined) { - res.sendStatus(404) - } else { - if (req.method === 'POST') res.status(201) - res.json(data) - } - }) - - return app -} diff --git a/src/bin.ts b/src/bin.ts deleted file mode 100644 index 4633e5e43..000000000 --- a/src/bin.ts +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from 'node:fs' -import { extname } from 'node:path' -import { parseArgs } from 'node:util' - -import chalk from 'chalk' -import { watch } from 'chokidar' -import JSON5 from 'json5' -import { Adapter, Low } from 'lowdb' -import { DataFile, JSONFile } from 'lowdb/node' -import { PackageJson } from 'type-fest' - -import { fileURLToPath } from 'node:url' -import { createApp } from './app.js' -import { Observer } from './observer.js' -import { Data } from './service.js' - -function help() { - console.log(`Usage: json-server [options] - -Options: - -p, --port Port (default: 3000) - -h, --host Host (default: localhost) - -s, --static Static files directory (multiple allowed) - --help Show this message - --version Show version number -`) -} - -// Parse args -function args(): { - file: string - port: number - host: string - static: string[] -} { - try { - const { values, positionals } = parseArgs({ - options: { - port: { - type: 'string', - short: 'p', - default: process.env['PORT'] ?? '3000', - }, - host: { - type: 'string', - short: 'h', - default: process.env['HOST'] ?? 'localhost', - }, - static: { - type: 'string', - short: 's', - multiple: true, - default: [], - }, - help: { - type: 'boolean', - }, - version: { - type: 'boolean', - }, - // Deprecated - watch: { - type: 'boolean', - short: 'w', - }, - }, - allowPositionals: true, - }) - - // --version - if (values.version) { - const pkg = JSON.parse( - readFileSync( - fileURLToPath(new URL('../package.json', import.meta.url)), - 'utf-8', - ), - ) as PackageJson - console.log(pkg.version) - process.exit() - } - - // Handle --watch - if (values.watch) { - console.log( - chalk.yellow( - '--watch/-w can be omitted, JSON Server 1+ watches for file changes by default', - ), - ) - } - - if (values.help || positionals.length === 0) { - help() - process.exit() - } - - // App args and options - return { - file: positionals[0] ?? '', - port: parseInt(values.port as string), - host: values.host as string, - static: values.static as string[], - } - } catch (e) { - if ((e as NodeJS.ErrnoException).code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') { - console.log(chalk.red((e as NodeJS.ErrnoException).message.split('.')[0])) - help() - process.exit(1) - } else { - throw e - } - } -} - -const { file, port, host, static: staticArr } = args() - -if (!existsSync(file)) { - console.log(chalk.red(`File ${file} not found`)) - process.exit(1) -} - -// Handle empty string JSON file -if (readFileSync(file, 'utf-8').trim() === '') { - writeFileSync(file, '{}') -} - -// Set up database -let adapter: Adapter -if (extname(file) === '.json5') { - adapter = new DataFile(file, { - parse: JSON5.parse, - stringify: JSON5.stringify, - }) -} else { - adapter = new JSONFile(file) -} -const observer = new Observer(adapter) - -const db = new Low(observer, {}) -await db.read() - -// Create app -const app = createApp(db, { logger: false, static: staticArr }) - -function logRoutes(data: Data) { - console.log(chalk.bold('Endpoints:')) - if (Object.keys(data).length === 0) { - console.log( - chalk.gray(`No endpoints found, try adding some data to ${file}`), - ) - return - } - console.log( - Object.keys(data) - .map( - (key) => `${chalk.gray(`http://${host}:${port}/`)}${chalk.blue(key)}`, - ) - .join('\n'), - ) -} - -const kaomojis = ['♡⸜(˶˃ ᵕ ˂˶)⸝♡', '♡( ◡‿◡ )', '( ˶ˆ ᗜ ˆ˵ )', '(˶ᵔ ᵕ ᵔ˶)'] - -function randomItem(items: string[]): string { - const index = Math.floor(Math.random() * items.length) - return items.at(index) ?? '' -} - -app.listen(port, () => { - console.log( - [ - chalk.bold(`JSON Server started on PORT :${port}`), - chalk.gray('Press CTRL-C to stop'), - chalk.gray(`Watching ${file}...`), - '', - chalk.magenta(randomItem(kaomojis)), - '', - chalk.bold('Index:'), - chalk.gray(`http://localhost:${port}/`), - '', - chalk.bold('Static files:'), - chalk.gray('Serving ./public directory if it exists'), - '', - ].join('\n'), - ) - logRoutes(db.data) -}) - -// Watch file for changes -if (process.env['NODE_ENV'] !== 'production') { - let writing = false // true if the file is being written to by the app - let prevEndpoints = '' - - observer.onWriteStart = () => { - writing = true - } - observer.onWriteEnd = () => { - writing = false - } - observer.onReadStart = () => { - prevEndpoints = JSON.stringify(Object.keys(db.data).sort()) - } - observer.onReadEnd = (data) => { - if (data === null) { - return - } - - const nextEndpoints = JSON.stringify(Object.keys(data).sort()) - if (prevEndpoints !== nextEndpoints) { - console.log() - logRoutes(data) - } - } - watch(file).on('change', () => { - // Do no reload if the file is being written to by the app - if (!writing) { - db.read().catch((e) => { - if (e instanceof SyntaxError) { - return console.log( - chalk.red(['', `Error parsing ${file}`, e.message].join('\n')), - ) - } - console.log(e) - }) - } - }) -} diff --git a/src/dom.js b/src/dom.js new file mode 100644 index 000000000..dffd5851a --- /dev/null +++ b/src/dom.js @@ -0,0 +1,54 @@ +import { getWeatherData } from '../data/weatherData.js'; +import { HourlyForecast } from '../components/HourlyForecast.js'; +import { WeeklyForecast } from '../components/WeeklyForecast.js'; + +export function loadApp() { + const searchInput = document.getElementById('search'); + const searchButton = document.getElementById('search-btn'); + + // Load default weather for Lier, Belgium + fetchWeather("Lier, Belgium"); + + // Search on button click + searchButton.addEventListener('click', () => fetchWeather(searchInput.value)); + + // Search on Enter key press + searchInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter') { + fetchWeather(searchInput.value); + } + }); + + // Real-time updates every 10 minutes + setInterval(() => fetchWeather("Lier, Belgium"), 600000); +} + +async function fetchWeather(query) { + const location = await getLocation(query); + + if (!location) { + displayError(`Location "${query}" not found. Please check the spelling.`); + return; + } + + const weatherData = await getWeatherData(location); + document.getElementById('hourly-forecast').innerHTML = HourlyForecast(weatherData.hourly, weatherData.weatherCodes); + document.getElementById('weekly-forecast').innerHTML = WeeklyForecast(weatherData.weekly); +} + +async function getLocation(query) { + const geoAPI = `https://nominatim.openstreetmap.org/search?format=json&q=${query}`; + const response = await fetch(geoAPI); + const data = await response.json(); + + if (data.length === 0) { + return null; // Return null if location is not found + } + + return { lat: data[0].lat, lon: data[0].lon }; +} + +function displayError(message) { + const forecastContainer = document.getElementById('hourly-forecast'); + forecastContainer.innerHTML = `

${message}

`; +} diff --git a/src/events.js b/src/events.js new file mode 100644 index 000000000..bbe90ad05 --- /dev/null +++ b/src/events.js @@ -0,0 +1,5 @@ +export const attachEventListeners = () => { + document.querySelector('.forecast-container').addEventListener('click', () => { + alert("Weather data updated!"); + }); +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 000000000..6b9dee2ac --- /dev/null +++ b/src/main.js @@ -0,0 +1,4 @@ +// UPDATED: Uses `load` event instead of `DOMContentLoaded` +import { loadApp } from './dom.js'; + +window.addEventListener('load', loadApp); diff --git a/src/observer.ts b/src/observer.ts deleted file mode 100644 index f2c890f92..000000000 --- a/src/observer.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Adapter } from 'lowdb' - -// Lowdb adapter to observe read/write events -export class Observer { - #adapter - - onReadStart = function () { - return - } - onReadEnd: (data: T | null) => void = function () { - return - } - onWriteStart = function () { - return - } - onWriteEnd = function () { - return - } - - constructor(adapter: Adapter) { - this.#adapter = adapter - } - - async read() { - this.onReadStart() - const data = await this.#adapter.read() - this.onReadEnd(data) - return data - } - - async write(arg: T) { - this.onWriteStart() - await this.#adapter.write(arg) - this.onWriteEnd() - } -} diff --git a/src/service.test.ts b/src/service.test.ts deleted file mode 100644 index 818a2457b..000000000 --- a/src/service.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { Low, Memory } from 'lowdb' - -import { Data, Item, PaginatedItems, Service } from './service.js' - -const defaultData = { posts: [], comments: [], object: {} } -const adapter = new Memory() -const db = new Low(adapter, defaultData) -const service = new Service(db) - -const POSTS = 'posts' -const COMMENTS = 'comments' -const OBJECT = 'object' - -const UNKNOWN_RESOURCE = 'xxx' -const UNKNOWN_ID = 'xxx' - -const post1 = { - id: '1', - title: 'a', - views: 100, - published: true, - author: { name: 'foo' }, - tags: ['foo', 'bar'], -} -const post2 = { - id: '2', - title: 'b', - views: 200, - published: false, - author: { name: 'bar' }, - tags: ['bar'], -} -const post3 = { - id: '3', - title: 'c', - views: 300, - published: false, - author: { name: 'baz' }, - tags: ['foo'], -} -const comment1 = { id: '1', title: 'a', postId: '1' } -const items = 3 - -const obj = { - f1: 'foo', -} - -function reset() { - db.data = structuredClone({ - posts: [post1, post2, post3], - comments: [comment1], - object: obj, - }) -} - -await test('constructor', () => { - const defaultData = { posts: [{ id: '1' }, {}], object: {} } satisfies Data - const db = new Low(adapter, defaultData) - new Service(db) - if (Array.isArray(db.data['posts'])) { - const id0 = db.data['posts']?.at(0)?.['id'] - const id1 = db.data['posts']?.at(1)?.['id'] - assert.ok( - typeof id1 === 'string' && id1.length > 0, - `id should be a non empty string but was: ${String(id1)}`, - ) - assert.ok( - typeof id0 === 'string' && id0 === '1', - `id should not change if already set but was: ${String(id0)}`, - ) - } -}) - -await test('findById', () => { - reset() - if (!Array.isArray(db.data?.[POSTS])) - throw new Error('posts should be an array') - assert.deepEqual(service.findById(POSTS, '1', {}), db.data?.[POSTS]?.[0]) - assert.equal(service.findById(POSTS, UNKNOWN_ID, {}), undefined) - assert.deepEqual(service.findById(POSTS, '1', { _embed: ['comments'] }), { - ...post1, - comments: [comment1], - }) - assert.deepEqual(service.findById(COMMENTS, '1', { _embed: ['post'] }), { - ...comment1, - post: post1, - }) - assert.equal(service.findById(UNKNOWN_RESOURCE, '1', {}), undefined) -}) - -await test('find', async (t) => { - const arr: { - data?: Data - name: string - params?: Parameters[1] - res: Item | Item[] | PaginatedItems | undefined - error?: Error - }[] = [ - { - name: POSTS, - res: [post1, post2, post3], - }, - { - name: POSTS, - params: { id: post1.id }, - res: [post1], - }, - { - name: POSTS, - params: { id: UNKNOWN_ID }, - res: [], - }, - { - name: POSTS, - params: { views: post1.views.toString() }, - res: [post1], - }, - { - name: POSTS, - params: { 'author.name': post1.author.name }, - res: [post1], - }, - { - name: POSTS, - params: { 'tags[0]': 'foo' }, - res: [post1, post3], - }, - { - name: POSTS, - params: { id: UNKNOWN_ID, views: post1.views.toString() }, - res: [], - }, - { - name: POSTS, - params: { views_ne: post1.views.toString() }, - res: [post2, post3], - }, - { - name: POSTS, - params: { views_lt: (post1.views + 1).toString() }, - res: [post1], - }, - { - name: POSTS, - params: { views_lt: post1.views.toString() }, - res: [], - }, - { - name: POSTS, - params: { views_lte: post1.views.toString() }, - res: [post1], - }, - { - name: POSTS, - params: { views_gt: post1.views.toString() }, - res: [post2, post3], - }, - { - name: POSTS, - params: { views_gt: (post1.views - 1).toString() }, - res: [post1, post2, post3], - }, - { - name: POSTS, - params: { views_gte: post1.views.toString() }, - res: [post1, post2, post3], - }, - { - name: POSTS, - params: { - views_gt: post1.views.toString(), - views_lt: post3.views.toString(), - }, - res: [post2], - }, - { - data: { posts: [post3, post1, post2] }, - name: POSTS, - params: { _sort: 'views' }, - res: [post1, post2, post3], - }, - { - data: { posts: [post3, post1, post2] }, - name: POSTS, - params: { _sort: '-views' }, - res: [post3, post2, post1], - }, - { - data: { posts: [post3, post1, post2] }, - name: POSTS, - params: { _sort: '-views,id' }, - res: [post3, post2, post1], - }, - { - name: POSTS, - params: { published: 'true' }, - res: [post1], - }, - { - name: POSTS, - params: { published: 'false' }, - res: [post2, post3], - }, - { - name: POSTS, - params: { views_lt: post3.views.toString(), published: 'false' }, - res: [post2], - }, - { - name: POSTS, - params: { _start: 0, _end: 2 }, - res: [post1, post2], - }, - { - name: POSTS, - params: { _start: 1, _end: 3 }, - res: [post2, post3], - }, - { - name: POSTS, - params: { _start: 0, _limit: 2 }, - res: [post1, post2], - }, - { - name: POSTS, - params: { _start: 1, _limit: 2 }, - res: [post2, post3], - }, - { - name: POSTS, - params: { _page: 1, _per_page: 2 }, - res: { - first: 1, - last: 2, - prev: null, - next: 2, - pages: 2, - items, - data: [post1, post2], - }, - }, - { - name: POSTS, - params: { _page: 2, _per_page: 2 }, - res: { - first: 1, - last: 2, - prev: 1, - next: null, - pages: 2, - items, - data: [post3], - }, - }, - { - name: POSTS, - params: { _page: 3, _per_page: 2 }, - res: { - first: 1, - last: 2, - prev: 1, - next: null, - pages: 2, - items, - data: [post3], - }, - }, - { - name: POSTS, - params: { _page: 2, _per_page: 1 }, - res: { - first: 1, - last: 3, - prev: 1, - next: 3, - pages: 3, - items, - data: [post2], - }, - }, - { - name: POSTS, - params: { _embed: ['comments'] }, - res: [ - { ...post1, comments: [comment1] }, - { ...post2, comments: [] }, - { ...post3, comments: [] }, - ], - }, - { - name: COMMENTS, - params: { _embed: ['post'] }, - res: [{ ...comment1, post: post1 }], - }, - { - name: UNKNOWN_RESOURCE, - res: undefined, - }, - { - name: OBJECT, - res: obj, - }, - ] - for (const tc of arr) { - await t.test(`${tc.name} ${JSON.stringify(tc.params)}`, () => { - if (tc.data) { - db.data = tc.data - } else { - reset() - } - - assert.deepEqual(service.find(tc.name, tc.params), tc.res) - }) - } -}) - -await test('create', async () => { - reset() - const post = { title: 'new post' } - const res = await service.create(POSTS, post) - assert.equal(res?.['title'], post.title) - assert.equal(typeof res?.['id'], 'string', 'id should be a string') - - assert.equal(await service.create(UNKNOWN_RESOURCE, post), undefined) -}) - -await test('update', async () => { - reset() - const obj = { f1: 'bar' } - const res = await service.update(OBJECT, obj) - assert.equal(res, obj) - - assert.equal( - await service.update(UNKNOWN_RESOURCE, obj), - undefined, - 'should ignore unknown resources', - ) - assert.equal( - await service.update(POSTS, {}), - undefined, - 'should ignore arrays', - ) -}) - -await test('updateById', async () => { - reset() - const post = { id: 'xxx', title: 'updated post' } - const res = await service.updateById(POSTS, post1.id, post) - assert.equal(res?.['id'], post1.id, 'id should not change') - assert.equal(res?.['title'], post.title) - - assert.equal( - await service.updateById(UNKNOWN_RESOURCE, post1.id, post), - undefined, - ) - assert.equal(await service.updateById(POSTS, UNKNOWN_ID, post), undefined) -}) - -await test('patchById', async () => { - reset() - const post = { id: 'xxx', title: 'updated post' } - const res = await service.patchById(POSTS, post1.id, post) - assert.notEqual(res, undefined) - assert.equal(res?.['id'], post1.id) - assert.equal(res?.['title'], post.title) - - assert.equal( - await service.patchById(UNKNOWN_RESOURCE, post1.id, post), - undefined, - ) - assert.equal(await service.patchById(POSTS, UNKNOWN_ID, post), undefined) -}) - -await test('destroy', async () => { - reset() - let prevLength = Number(db.data?.[POSTS]?.length) || 0 - await service.destroyById(POSTS, post1.id) - assert.equal(db.data?.[POSTS]?.length, prevLength - 1) - assert.deepEqual(db.data?.[COMMENTS], [{ ...comment1, postId: null }]) - - reset() - prevLength = db.data?.[POSTS]?.length || 0 - await service.destroyById(POSTS, post1.id, [COMMENTS]) - assert.equal(db.data[POSTS].length, prevLength - 1) - assert.equal(db.data[COMMENTS].length, 0) - - assert.equal(await service.destroyById(UNKNOWN_RESOURCE, post1.id), undefined) - assert.equal(await service.destroyById(POSTS, UNKNOWN_ID), undefined) -}) diff --git a/src/service.ts b/src/service.ts deleted file mode 100644 index 0c1bdd294..000000000 --- a/src/service.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { randomBytes } from 'node:crypto' - -import { getProperty } from 'dot-prop' -import inflection from 'inflection' -import { Low } from 'lowdb' -import sortOn from 'sort-on' - -export type Item = Record - -export type Data = Record - -export function isItem(obj: unknown): obj is Item { - return typeof obj === 'object' && obj !== null -} - -export function isData(obj: unknown): obj is Record { - if (typeof obj !== 'object' || obj === null) { - return false - } - - const data = obj as Record - return Object.values(data).every( - (value) => Array.isArray(value) && value.every(isItem), - ) -} - -enum Condition { - lt = 'lt', - lte = 'lte', - gt = 'gt', - gte = 'gte', - ne = 'ne', - default = '', -} - -function isCondition(value: string): value is Condition { - return Object.values(Condition).includes(value) -} - -export type PaginatedItems = { - first: number - prev: number | null - next: number | null - last: number - pages: number - items: number - data: Item[] -} - -function ensureArray(arg: string | string[] = []): string[] { - return Array.isArray(arg) ? arg : [arg] -} - -function embed(db: Low, name: string, item: Item, related: string): Item { - if (inflection.singularize(related) === related) { - const relatedData = db.data[inflection.pluralize(related)] as Item[] - if (!relatedData) { - return item - } - const foreignKey = `${related}Id` - const relatedItem = relatedData.find((relatedItem: Item) => { - return relatedItem['id'] === item[foreignKey] - }) - return { ...item, [related]: relatedItem } - } - const relatedData: Item[] = db.data[related] as Item[] - - if (!relatedData) { - return item - } - - const foreignKey = `${inflection.singularize(name)}Id` - const relatedItems = relatedData.filter( - (relatedItem: Item) => relatedItem[foreignKey] === item['id'], - ) - - return { ...item, [related]: relatedItems } -} - -function nullifyForeignKey(db: Low, name: string, id: string) { - const foreignKey = `${inflection.singularize(name)}Id` - - Object.entries(db.data).forEach(([key, items]) => { - // Skip - if (key === name) return - - // Nullify - if (Array.isArray(items)) { - items.forEach((item) => { - if (item[foreignKey] === id) { - item[foreignKey] = null - } - }) - } - }) -} - -function deleteDependents(db: Low, name: string, dependents: string[]) { - const foreignKey = `${inflection.singularize(name)}Id` - - Object.entries(db.data).forEach(([key, items]) => { - // Skip - if (key === name || !dependents.includes(key)) return - - // Delete if foreign key is null - if (Array.isArray(items)) { - db.data[key] = items.filter((item) => item[foreignKey] !== null) - } - }) -} - -function randomId(): string { - return randomBytes(2).toString('hex') -} - -function fixItemsIds(items: Item[]) { - items.forEach((item) => { - if (typeof item['id'] === 'number') { - item['id'] = item['id'].toString() - } - if (item['id'] === undefined) { - item['id'] = randomId() - } - }) -} - -// Ensure all items have an id -function fixAllItemsIds(data: Data) { - Object.values(data).forEach((value) => { - if (Array.isArray(value)) { - fixItemsIds(value) - } - }) -} - -export class Service { - #db: Low - - constructor(db: Low) { - fixAllItemsIds(db.data) - this.#db = db - } - - #get(name: string): Item[] | Item | undefined { - return this.#db.data[name] - } - - has(name: string): boolean { - return Object.prototype.hasOwnProperty.call(this.#db?.data, name) - } - - findById( - name: string, - id: string, - query: { _embed?: string[] | string }, - ): Item | undefined { - const value = this.#get(name) - - if (Array.isArray(value)) { - let item = value.find((item) => item['id'] === id) - ensureArray(query._embed).forEach((related) => { - if (item !== undefined) item = embed(this.#db, name, item, related) - }) - return item - } - - return - } - - find( - name: string, - query: { - [key: string]: unknown - _embed?: string | string[] - _sort?: string - _start?: number - _end?: number - _limit?: number - _page?: number - _per_page?: number - } = {}, - ): Item[] | PaginatedItems | Item | undefined { - let items = this.#get(name) - - if (!Array.isArray(items)) { - return items - } - - // Include - ensureArray(query._embed).forEach((related) => { - if (items !== undefined && Array.isArray(items)) { - items = items.map((item) => embed(this.#db, name, item, related)) - } - }) - - // Return list if no query params - if (Object.keys(query).length === 0) { - return items - } - - // Convert query params to conditions - const conds: [string, Condition, string | string[]][] = [] - for (const [key, value] of Object.entries(query)) { - if (value === undefined || typeof value !== 'string') { - continue - } - const re = /_(lt|lte|gt|gte|ne)$/ - const reArr = re.exec(key) - const op = reArr?.at(1) - if (op && isCondition(op)) { - const field = key.replace(re, '') - conds.push([field, op, value]) - continue - } - if ( - [ - '_embed', - '_sort', - '_start', - '_end', - '_limit', - '_page', - '_per_page', - ].includes(key) - ) { - continue - } - conds.push([key, Condition.default, value]) - } - - // Loop through conditions and filter items - let filtered = items - for (const [key, op, paramValue] of conds) { - filtered = filtered.filter((item: Item) => { - if (paramValue && !Array.isArray(paramValue)) { - // https://github.com/sindresorhus/dot-prop/issues/95 - const itemValue: unknown = getProperty(item, key) - switch (op) { - // item_gt=value - case Condition.gt: { - if ( - !( - typeof itemValue === 'number' && - itemValue > parseInt(paramValue) - ) - ) { - return false - } - break - } - // item_gte=value - case Condition.gte: { - if ( - !( - typeof itemValue === 'number' && - itemValue >= parseInt(paramValue) - ) - ) { - return false - } - break - } - // item_lt=value - case Condition.lt: { - if ( - !( - typeof itemValue === 'number' && - itemValue < parseInt(paramValue) - ) - ) { - return false - } - break - } - // item_lte=value - case Condition.lte: { - if ( - !( - typeof itemValue === 'number' && - itemValue <= parseInt(paramValue) - ) - ) { - return false - } - break - } - // item_ne=value - case Condition.ne: { - switch (typeof itemValue) { - case 'number': - return itemValue !== parseInt(paramValue) - case 'string': - return itemValue !== paramValue - case 'boolean': - return itemValue !== (paramValue === 'true') - } - break - } - // item=value - case Condition.default: { - switch (typeof itemValue) { - case 'number': - return itemValue === parseInt(paramValue) - case 'string': - return itemValue === paramValue - case 'boolean': - return itemValue === (paramValue === 'true') - } - } - } - } - return true - }) - } - - // Sort - const sort = query._sort || '' - const sorted = sortOn(filtered, sort.split(',')) - - // Slice - const start = query._start - const end = query._end - const limit = query._limit - if (start !== undefined) { - if (end !== undefined) { - return sorted.slice(start, end) - } - return sorted.slice(start, start + (limit || 0)) - } - if (limit !== undefined) { - return sorted.slice(0, limit) - } - - // Paginate - let page = query._page - const perPage = query._per_page || 10 - if (page) { - const items = sorted.length - const pages = Math.ceil(items / perPage) - - // Ensure page is within the valid range - page = Math.max(1, Math.min(page, pages)) - - const first = 1 - const prev = page > 1 ? page - 1 : null - const next = page < pages ? page + 1 : null - const last = pages - - const start = (page - 1) * perPage - const end = start + perPage - const data = sorted.slice(start, end) - - return { - first, - prev, - next, - last, - pages, - items, - data, - } - } - - return sorted.slice(start, end) - } - - async create( - name: string, - data: Omit = {}, - ): Promise { - const items = this.#get(name) - if (items === undefined || !Array.isArray(items)) return - - const item = { id: randomId(), ...data } - items.push(item) - - await this.#db.write() - return item - } - - async #updateOrPatch( - name: string, - body: Item = {}, - isPatch: boolean, - ): Promise { - const item = this.#get(name) - if (item === undefined || Array.isArray(item)) return - - const nextItem = (this.#db.data[name] = isPatch ? { item, ...body } : body) - - await this.#db.write() - return nextItem - } - - async #updateOrPatchById( - name: string, - id: string, - body: Item = {}, - isPatch: boolean, - ): Promise { - const items = this.#get(name) - if (items === undefined || !Array.isArray(items)) return - - const item = items.find((item) => item['id'] === id) - if (!item) return - - const nextItem = isPatch ? { ...item, ...body, id } : { ...body, id } - const index = items.indexOf(item) - items.splice(index, 1, nextItem) - - await this.#db.write() - return nextItem - } - - async update(name: string, body: Item = {}): Promise { - return this.#updateOrPatch(name, body, false) - } - - async patch(name: string, body: Item = {}): Promise { - return this.#updateOrPatch(name, body, true) - } - - async updateById( - name: string, - id: string, - body: Item = {}, - ): Promise { - return this.#updateOrPatchById(name, id, body, false) - } - - async patchById( - name: string, - id: string, - body: Item = {}, - ): Promise { - return this.#updateOrPatchById(name, id, body, true) - } - - async destroyById( - name: string, - id: string, - dependent?: string | string[], - ): Promise { - const items = this.#get(name) - if (items === undefined || !Array.isArray(items)) return - - const item = items.find((item) => item['id'] === id) - if (item === undefined) return - const index = items.indexOf(item) - items.splice(index, 1) - - nullifyForeignKey(this.#db, name, id) - const dependents = ensureArray(dependent) - deleteDependents(this.#db, name, dependents) - - await this.#db.write() - return item - } -} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 000000000..e66a7ffc4 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,3 @@ +export const formatTemperature = (temp) => { + return `${temp.toFixed(1)}°C`; +} diff --git a/styles/main.css b/styles/main.css new file mode 100644 index 000000000..2d72239d1 --- /dev/null +++ b/styles/main.css @@ -0,0 +1,70 @@ +/* UPDATED: Added styles for search bar and column layout */ +body { + font-family: Arial, sans-serif; + background: linear-gradient(to right, #4facfe, #00f2fe); + color: white; + text-align: center; +} + +.search-container { + margin: 20px; +} + +input { + padding: 10px; + width: 200px; +} + +button { + padding: 10px; + cursor: pointer; +} + +.forecast-container { + background: rgba(255, 255, 255, 0.2); + padding: 20px; + border-radius: 10px; + display: inline-block; + margin-top: 20px; +} + +.forecast-list { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 10px; +} + +.weather-card { + background: rgba(255, 255, 255, 0.3); + padding: 15px; + border-radius: 8px; + width: 100px; +} + +/* UPDATED: Added styles for weather icons */ +.weather-card img { + width: 50px; + height: 50px; + margin: 5px; +} + +/* UPDATED: Added styles for hourly and weekly forecasts */ +.forecast-container { + background: rgba(255, 255, 255, 0.2); + padding: 20px; + border-radius: 10px; + display: inline-block; + margin-top: 20px; +} + +.forecast-list { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} + +.weather-card img { + width: 50px; + height: 50px; + margin: 5px; +} diff --git a/tests/apiHandler.test.js b/tests/apiHandler.test.js new file mode 100644 index 000000000..8d0cdc865 --- /dev/null +++ b/tests/apiHandler.test.js @@ -0,0 +1,6 @@ +import { getWeatherData } from '../data/weatherData.js'; + +test('getWeatherData should return an array', async () => { + const data = await getWeatherData(); + expect(Array.isArray(data)).toBe(true); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js new file mode 100644 index 000000000..9b115dd41 --- /dev/null +++ b/tests/utils.test.js @@ -0,0 +1,7 @@ +import { formatTemperature } from '../scripts/utils.js'; + +test('formatTemperature should format temperature correctly', () => { + expect(formatTemperature(25)).toBe('25.0°C'); + expect(formatTemperature(30.567)).toBe('30.6°C'); + expect(formatTemperature(-5)).toBe('-5.0°C'); +}); diff --git a/views/index.html b/views/index.html deleted file mode 100644 index 96f63c8eb..000000000 --- a/views/index.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - - - - - -
- -
-
-

✧*。٩(ˊᗜˋ*)و✧*。

- <% if (Object.keys(it.data).length===0) { %> -

No resources found in JSON file

- <% } %> - <% Object.entries(it.data).forEach(function([name]) { %> -
    -
  • - /<%= name %> - - <% if (Array.isArray(it.data[name])) { %> - - <%= it.data[name].length %> - <%= it.data[name].length> 1 ? 'items' : 'item' %> - - <% } %> -
  • -
- <% }) %> -
- - - \ No newline at end of file