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)}
+

+
${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