Skip to content

src/app.ts

134 lines · 5.0 KB · TypeScript
Raw
import { packageContext } from 'kody:runtime'
import { getPollen } from './getPollen'

function appUrl(path: string): URL {
	if (!packageContext?.hostedUrl) {
		throw new Error('This module must run as a package app.')
	}
	const relative = path.replace(/^\/+/, '')
	const mount = packageContext.appBasePath.endsWith('/')
		? packageContext.appBasePath
		: `${packageContext.appBasePath}/`
	return new URL(`${mount}${relative}`, packageContext.hostedUrl)
}

function renderPage(): Response {
	const apiPath = appUrl('api/getPollen').pathname
	const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Pollen levels</title>
  <style>
    :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e2e8f0; }
    main { width: min(640px, 92vw); background: #1e293b; border-radius: 16px; padding: 1.5rem; box-shadow: 0 20px 50px rgba(0,0,0,.35); }
    h1 { margin: 0 0 .25rem; font-size: 1.4rem; }
    p.lead { margin: 0 0 1rem; color: #94a3b8; font-size: .95rem; }
    label { display: block; margin-bottom: .4rem; font-size: .9rem; color: #cbd5e1; }
    input { width: 100%; border-radius: 10px; border: 1px solid #334155; background: #0f172a; color: inherit; padding: .75rem; font-size: 1.05rem; box-sizing: border-box; }
    .row { display: flex; gap: .75rem; align-items: center; margin: .9rem 0 1rem; flex-wrap: wrap; }
    button { border-radius: 999px; border: 0; padding: .55rem 1.1rem; font-size: .95rem; background: #34d399; color: #0f172a; font-weight: 600; cursor: pointer; }
    button:disabled { opacity: .6; cursor: wait; }
    .out { min-height: 3rem; padding: 1rem; border-radius: 10px; background: #0f172a; border: 1px solid #334155; white-space: pre-wrap; word-break: break-word; font-size: .95rem; }
    .meta { margin-top: .6rem; color: #94a3b8; font-size: .85rem; }
    .err { color: #fca5a5; margin-top: .6rem; }
    .note { margin-top: 1rem; color: #64748b; font-size: .8rem; }
  </style>
</head>
<body>
  <main>
    <h1>Pollen levels</h1>
    <p class="lead">City name → Open-Meteo pollen grains/m³ (Europe coverage; elsewhere often unavailable).</p>
    <label for="city">City</label>
    <input id="city" value="Berlin" placeholder="e.g. Berlin, Paris, Vienna" />
    <div class="row">
      <button id="go" type="button">Check pollen</button>
    </div>
    <label>Result</label>
    <div class="out" id="out">—</div>
    <div class="meta" id="meta"></div>
    <div class="err" id="err"></div>
    <p class="note">Not medical advice. Data: Open-Meteo / CAMS.</p>
  </main>
  <script>
    const apiPath = ${JSON.stringify(apiPath)};
    const cityEl = document.getElementById('city');
    const outEl = document.getElementById('out');
    const metaEl = document.getElementById('meta');
    const errEl = document.getElementById('err');
    const goEl = document.getElementById('go');

    async function run() {
      errEl.textContent = '';
      goEl.disabled = true;
      try {
        const res = await fetch(apiPath, {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify({ location: cityEl.value, hours: 24 }),
        });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
        outEl.textContent = data.summary || JSON.stringify(data.levels, null, 2);
        const place = data.place ? (data.place.name + (data.place.country ? ', ' + data.place.country : '')) : '';
        metaEl.textContent = [place, data.coverage, data.time].filter(Boolean).join(' · ');
      } catch (e) {
        outEl.textContent = '—';
        metaEl.textContent = '';
        errEl.textContent = String(e && e.message ? e.message : e);
      } finally {
        goEl.disabled = false;
      }
    }

    goEl.addEventListener('click', run);
    cityEl.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') run();
    });
  </script>
</body>
</html>`
	return new Response(html, {
		headers: {
			'content-type': 'text/html; charset=utf-8',
			'cache-control': 'no-store',
		},
	})
}

async function handleApi(request: Request): Promise<Response> {
	if (request.method !== 'POST') {
		return Response.json({ error: 'POST required' }, { status: 405 })
	}
	let body: {
		location?: string
		latitude?: number
		longitude?: number
		hours?: number
	} = {}
	try {
		body = (await request.json()) as typeof body
	} catch {
		return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
	}
	try {
		const result = await getPollen(body)
		return Response.json(result)
	} catch (e) {
		return Response.json(
			{ error: e instanceof Error ? e.message : String(e) },
			{ status: 400 },
		)
	}
}

export default {
	async fetch(request: Request): Promise<Response> {
		const path = new URL(request.url).pathname
		if (path === '/' || path === '') return renderPage()
		if (path === '/api/getPollen') return handleApi(request)
		return new Response('Not found', { status: 404 })
	},
}