Skip to content

Commit

Permalink
Merge pull request #5 from catdad-experiments/manual-location-selection
Browse files Browse the repository at this point in the history
implementing location search for looking up weather
  • Loading branch information
catdad authored Jan 27, 2024
2 parents b897bce + 861d53f commit 2abd1b6
Show file tree
Hide file tree
Showing 16 changed files with 458 additions and 72 deletions.
62 changes: 55 additions & 7 deletions src/hooks/location.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,71 @@
import { createContext, useContext, useEffect, html, useSignal } from '../preact.js';
import { createContext, useContext, useEffect, html, useSignal, batch } from '../preact.js';
import { getPosition } from '../sources/position.js';
import { useRoutes } from './routes.js';
import { persistent } from '../sources/persistent.js';

const Location = createContext({});

export const withLocation = Component => ({ children, ...props }) => {
const location = useSignal(null);
const location = useSignal({});
const history = useSignal([]);
const { route, ROUTES } = useRoutes();

const useDeviceLocation = async () => {
const position = await getPosition();
console.log('got new location', position);
setLocation({
...position,
type: 'device'
});
};

const setLocation = ({ latitude, longitude, description, type = 'manual' }) => {
batch(() => {
const value = {
latitude,
longitude,
description,
type
};

persistent.set('location', { ...value });

location.value = { ...value };
history.value = [...history.value, { ...value }];
});
};

useEffect(() => {
getPosition().then(position => {
console.log('got new location', position);
location.value = { ...position };
}).catch(err => {
(async () => {
const storedLocation = persistent.get('location');

// if user has a previous saved location, open that location
// initially and let them change it later if they want
if (storedLocation && storedLocation.type === 'manual') {
setLocation(storedLocation);
return;
}

const { state: geolocationPermission } = await navigator.permissions.query({ name: 'geolocation' });

if (geolocationPermission === 'granted') {
await useDeviceLocation();
} else {
route.value = ROUTES.location;
}
})().catch(err => {
// TODO
console.error('failed to get position:', err);
route.value = ROUTES.location;
});
}, []);

if (!location.value && route.value !== ROUTES.location) {
return html`<div>Getting location...<//>`;
}

return html`
<${Location.Provider} value=${{ location }}>
<${Location.Provider} value=${{ location, history, useDeviceLocation, setLocation }}>
<${Component} ...${props}>${children}<//>
<//>
`;
Expand Down
9 changes: 7 additions & 2 deletions src/hooks/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { createContext, useContext, html, useSignal } from '../preact.js';

const Routes = createContext({});

const ROUTES = Object.freeze({
forecast: 'forecast',
location: 'location'
});

export const withRoutes = Component => ({ children, ...props }) => {
const route = useSignal('forecast');
const route = useSignal(ROUTES.forecast);
const routeData = useSignal(null);

return html`
<${Routes.Provider} value=${{ route, routeData }}>
<${Routes.Provider} value=${{ route, routeData, ROUTES }}>
<${Component} ...${props}>${children}<//>
<//>
`;
Expand Down
27 changes: 16 additions & 11 deletions src/hooks/weather.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useContext, html, effect, useSignal } from '../preact.js';
import { createContext, useContext, html, useSignal, useEffect } from '../preact.js';
import { getForecast } from '../sources/forecast.js';

import { useLocation } from './location.js';
Expand All @@ -12,14 +12,13 @@ export const withWeather = Component => ({ children, ...props }) => {
const windspeedUnit = useSignal('mph'); // kmh, ms, mph, kn,
const precipitationUnit = useSignal('inch'); // mm, inch

// when coordinates change, fetch the new weather
effect(() => {
if (!location.value) {
const refreshForecast = async () => {
const { latitude, longitude } = location.value;

if (latitude === undefined) {
return;
}

const { latitude, longitude } = location.value;

const query = {
latitude,
longitude,
Expand All @@ -28,16 +27,22 @@ export const withWeather = Component => ({ children, ...props }) => {
precipitation_unit: precipitationUnit.value
};

getForecast(query).then(result => {
weather.value = result;
}).catch(err => {
const result = await getForecast(query);
weather.value = result;
};

// when coordinates change, fetch the new weather
// for some reason, `effect` is executed twice on
// a single `location` change
useEffect(() => {
refreshForecast().catch(err => {
// TODO
console.error('failed to fetch weather data:', err);
});
});
}, [location.value.latitude, location.value.longitude]);

return html`
<${Weather.Provider} value=${{ weather }}>
<${Weather.Provider} value=${{ weather, refreshForecast }}>
<${Component} ...${props}>${children}<//>
<//>
`;
Expand Down
36 changes: 26 additions & 10 deletions src/pages/forecast.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
import { html } from '../preact.js';
import { useWeather } from "../hooks/weather.js";
import { useWeather } from '../hooks/weather.js';
import { useLocation } from '../hooks/location.js';
import { useRoutes } from '../hooks/routes.js';
import { getDateTime } from "../utils.js";
import { useStyle } from '../hooks/style.js';

import { CurrentLocation } from "../ui/current-location.js";
import { Scaffold } from '../ui/scaffold.js';
import { LocationChip } from "../ui/current-location.js";
import { CurrentWeather } from "../ui/current-weather.js";
import { DailyForecast } from "../ui/daily-forecast.js";

export const Forecast = () => {
const { location } = useLocation();
const { weather } = useWeather();
const { weather, refreshForecast } = useWeather();
const { route, ROUTES } = useRoutes();

const classname = useStyle(`
$ .refresh {
text-align: center;
margin: var(--spacing);
}
`);

if (!location.value || !weather.value) {
return html`<div>Working on it...</div>`;
}

return html`
<${CurrentLocation} />
const onLocationClick = () => {
route.value = ROUTES.location;
};

return html`<${Scaffold}
class="${classname}"
header=${html`<${LocationChip} onClick=${onLocationClick} />`}
>
<${CurrentWeather} />
<div>
<${DailyForecast} />
<div class="refresh">
refreshed: ${getDateTime(weather.value.date)}
<span> <//>
<button onclick=${() => {
location.value = {...location.value};
refreshForecast();
}}>refresh now<//>
<//>
<${DailyForecast} />
<div><a href="https://open-meteo.com/">Weather data by Open-Meteo.com</a><//>
`;
<//>`;
};
131 changes: 131 additions & 0 deletions src/pages/location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { html, useSignal, batch } from '../preact.js';
import { useLocation } from '../hooks/location.js';
import { useStyle } from '../hooks/style.js';
import { useRoutes } from '../hooks/routes.js';
import { geocode } from '../sources/geocode.js';

import { Scaffold } from '../ui/scaffold.js';
import { Button } from '../ui/button.js';
import { LocationDetails, LocationChip } from "../ui/current-location.js";
import { Emoji } from '../ui/emoji.js';

// https://dev.to/jorik/country-code-to-flag-emoji-a21
function getFlagEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}

const UseDeviceLocation = () => {
const { history, useDeviceLocation } = useLocation();
const { route, ROUTES } = useRoutes();

const deviceLocation = history.value.find(a => a.type === 'device');

const classname = useStyle(`
$ {
display: flex;
flex-direction: column;
align-items: center;
}
$ button {
display: block;
}
`);

return html`<div class="${classname}">
${deviceLocation ? html`<${LocationDetails} location=${deviceLocation} />` : null}
<${Button} onClick=${() => {
useDeviceLocation().then(() => {
route.value = ROUTES.forecast;
}).catch(err => {
console.log('failed to use device location on user request:', err);
});
}}>Use device location<//>
<//>`;
};

const SearchResults = ({ results }) => {
const { setLocation } = useLocation();
const { route, ROUTES } = useRoutes();

const classname = useStyle(`
$ {
margin-top: 2rem;
display: flex;
flex-direction: column;
align-items: center;
}
$ .result {
display: grid;
grid-template-columns: max-content auto;
gap: 1rem 1rem;
padding: 1rem;
width: 100%;
}
$ .result:nth-child(even) {
background: rgba(255,255,255,0.1);
border-radius: 5px;
}
$ .description {
text-align: center;
}
`);

return html`<div class="${classname} limit">
${(() => {
if (!results) {
return html`<div>Type a city name to search<//>`;
}
if (results.length === 0) {
return html`<div>No results found<//>`;
}
return results.map(place => html`<div key="${place.description}" class="result" onClick=${() => {
batch(() => {
setLocation({
latitude: place.latitude,
longitude: place.longitude,
description: `${place.name}, ${place.description}`
});
route.value = ROUTES.forecast;
});
}}>
<${Emoji}>${getFlagEmoji(place.countryCode)}<//>
<span class="description">${place.name}, ${place.description}<//>
<//>`);
})()}
<//>`;
};

export const Location = () => {
const searchResults = useSignal(null);

const onInputChange = ({ value }) => {
if (value === '') {
searchResults.value = null;
return;
}

geocode(value).then(results => {
console.log(`📍 "${value}" lookup:`, results);
searchResults.value = results;
}).catch(err => {
console.log(`failed to geocode "${value}":`, err);
});
};

return html`<${Scaffold}
header=${html`<${LocationChip} editable autofocus onChange=${onInputChange} />`}
>
<${UseDeviceLocation} />
<${SearchResults} results=${searchResults.value} />
<//>`;
};
7 changes: 5 additions & 2 deletions src/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { withWeather } from './hooks/weather.js';
import { useRoutes, withRoutes } from './hooks/routes.js';

import { Forecast } from './pages/forecast.js';
import { Location } from './pages/location.js';

const Router = () => {
const { route } = useRoutes();
const { route, ROUTES } = useRoutes();

switch (route.value) {
case 'forecast':
case ROUTES.forecast:
return html`<${Forecast} />`;
case ROUTES.location:
return html`<${Location} />`;
default:
return html`<div>Loading...</div>`;
}
Expand Down
10 changes: 8 additions & 2 deletions src/sources/forecast.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ const codes = {
99: { name: 'Thunderstorm with heavy hail', icon: '⛈' },
};

const getOpenMeteoResponse = async queryString => {
const res = await fetchOk(`https://api.open-meteo.com/v1/forecast?${queryString}`);
const json = await res.json();

return json;
};

export const getForecast = async (query) => {
const now = new Date();
const shorten = x => Math.round(x * 1000) / 1000;
Expand Down Expand Up @@ -76,8 +83,7 @@ export const getForecast = async (query) => {
const tempUnit = ({ celcius: '°C', fahrenheit: '°F'})[query.temperature_unit];
const precipUnit = ({ inch: 'in', mm: 'mm' })[query.precipitation_unit];

const res = await fetchOk(`https://api.open-meteo.com/v1/forecast?${queryString}`);
const json = await res.json();
const json = await getOpenMeteoResponse(queryString);

let currentFeelsLike;
const thisDay = getDay(new Date());
Expand Down
Loading

0 comments on commit 2abd1b6

Please sign in to comment.