Skip to content

Commit 84863af

Browse files
committed
wip: opening hours and ac images
1 parent ab40f02 commit 84863af

File tree

6 files changed

+267
-27
lines changed

6 files changed

+267
-27
lines changed

TODO.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@
1313
* [x] Image
1414
* [x] Category / Subtitle
1515
* [x] a11y stuff
16+
* [x] Opening Hours
1617
* [ ] Anbindung an Filterung und Backend
1718
* [x] Wenn wir filtern, wird die Liste angezeit
1819
* [x] Die Daten in der Liste kommen aus dem Backend
1920
* [ ] Es gibt Pagination in der Liste
2021
* [ ] Wir zeigen auch a11ycloud Orte in der Liste an
2122
* [ ] Refactoring
2223
* [x] url und coordinates in funktionen auslagern
24+
* [ ] Opening Hours: Komplexe Öffnungszeiten handlen. "Wahrscheinlich offen" sollte checken, ob der nächste Change am
25+
* selben Tag stattfindet oder nicht. Saisonale Öffnungszeiten: Einige Orte sind evtl. saisonal geschlossen. "Next Change" sollte auf Monatsebene abgeglichen werden.
26+
* [ ] Der Opening-Hours-Komponenten Code ist gedoppelt
27+
* [ ] Die Lokalisierung in der OH-Komponente sollte den Kontext benutzen
28+
* [ ] Die WikidaraEntityImage Komponente evtl in einen Hook und eine Renderfunction splitten
2329
* [ ] alles hübsch machen
30+
* [ ] statt bounding box sollten tiles zun laden der orte benutzt werden (mit tilebelt findet man kleineste teil zu einer bounding box: https://github.com/mapbox/tilebelt)
2431
* [ ] Usability
2532
* [ ] wenn ich in der Karte navigiere auf mobile, collapsed die sidebar/das sheet
2633
* [ ] wenn ich auf ein list-item klicke, panned die karte langsam
@@ -31,7 +38,6 @@
3138

3239
## Für später
3340

34-
* opening hours
3541
* distance to current location
3642
* wie viele orte gefunden wurden, text-anzeige
3743
* info button mit dialog, der erklärt, wie wir orte anzeigen und filtern

src/components/CombinedFeaturePanel/components/AccessibilitySection/OpeningHoursValue.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export default function OpeningHoursValue(props: {
7272
locale: navigator.language.slice(0, 2),
7373
tag_key: tagKey,
7474
map_value: true,
75-
mode: mode.both,
76-
warnings_severity: warnings_severity.info,
75+
mode: 2,
76+
warnings_severity: 6,
7777
},
7878
);
7979
const isOpen = oh.getState(); // for current date

src/components/CombinedFeaturePanel/components/FeatureGallery.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import useAccessibilityCloud from "~/modules/accessibility-cloud/hooks/useAccess
66
import { Gallery } from "./Gallery/Gallery";
77
import { makeImageIds, makeImageLocation } from "./Gallery/util";
88

9-
const fetcher = (urls: string[]) => {
9+
export const imageFetcher = (urls: string[]) => {
1010
const f = (u) =>
1111
fetch(u).then((r) => {
1212
if (r.ok) {
@@ -33,7 +33,7 @@ export const FeatureGallery: FC<{
3333
baseUrl && appToken
3434
? ids.map((x) => makeImageLocation(baseUrl, appToken, x.context, x.id))
3535
: null,
36-
fetcher,
36+
imageFetcher,
3737
);
3838
const images = useMemo(() => data?.flatMap((x) => x.images) ?? [], [data]);
3939

src/components/CombinedFeaturePanel/components/image/WikidataEntityImage.tsx

+46-19
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/* eslint-disable @next/next/no-img-element */
22
import { omit } from "lodash";
3-
import type { HTMLAttributes } from "react";
3+
import { type HTMLAttributes, useMemo } from "react";
44
import useSWR from "swr";
55
import { t } from "ttag";
6+
import { imageFetcher } from "~/components/CombinedFeaturePanel/components/FeatureGallery";
7+
import {
8+
makeImageIds,
9+
makeImageLocation,
10+
} from "~/components/CombinedFeaturePanel/components/Gallery/util";
11+
import Image from "~/components/Image";
12+
import type { AnyFeature } from "~/lib/model/geo/AnyFeature";
13+
import useAccessibilityCloud from "~/modules/accessibility-cloud/hooks/useAccessibilityCloud";
614
import type OSMFeature from "../../../../lib/model/osm/OSMFeature";
715

816
const fetcher = (url: string) => fetch(url).then((r) => r.json());
@@ -35,24 +43,43 @@ export default function WikidataEntityImage(props: Props) {
3543
props.verb,
3644
)}&format=json`;
3745
const { data, error } = useSWR(entityId ? url : null, fetcher);
38-
if (error) return null;
39-
if (!data) return null;
40-
try {
41-
const { results } = data;
42-
const { bindings } = results;
43-
const { o } = bindings[0];
44-
const { value } = o;
45-
const logoUrl = `${value.replace(/^http:/, "https:")}?width=200`;
46-
47-
const image = (
48-
<img
49-
{...omit(props, "feature", "prefix", "verb")}
50-
src={logoUrl}
51-
aria-label={t`Place photo`}
52-
/>
53-
);
54-
return image;
55-
} catch (e) {
46+
const imageIds = makeImageIds(props.feature as AnyFeature);
47+
const { baseUrl, appToken } = useAccessibilityCloud({ cached: true });
48+
const { data: acData } = useSWR(
49+
baseUrl && appToken
50+
? imageIds.map((x) =>
51+
makeImageLocation(baseUrl, appToken, x.context, x.id),
52+
)
53+
: null,
54+
imageFetcher,
55+
);
56+
const images = useMemo(
57+
() => acData?.flatMap((x) => x.images) ?? [],
58+
[acData],
59+
);
60+
61+
if (!error && data) {
62+
try {
63+
const { results } = data;
64+
const { bindings } = results;
65+
const { o } = bindings[0];
66+
const { value } = o;
67+
const logoUrl = `${value.replace(/^http:/, "https:")}?width=200`;
68+
69+
const image = (
70+
<img
71+
{...omit(props, "feature", "prefix", "verb")}
72+
src={logoUrl}
73+
aria-label={t`Place photo`}
74+
/>
75+
);
76+
return image;
77+
} catch (e) {}
78+
}
79+
80+
if (!images?.[0]) {
5681
return null;
5782
}
83+
84+
return <Image {...props} image={images[0]} width={60} height={60} alt="" />;
5885
}

src/modules/list/ListItem.tsx

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Flex } from "@radix-ui/themes";
2-
import { useEffect, useState } from "react";
2+
import { useState } from "react";
33
import styled from "styled-components";
44
import { t } from "ttag";
55
import { AppStateLink } from "~/components/App/AppStateLink";
@@ -12,6 +12,7 @@ import { isOrHasAccessibleToilet } from "~/lib/model/accessibility/isOrHasAccess
1212
import { isWheelchairAccessible } from "~/lib/model/accessibility/isWheelchairAccessible";
1313
import type { AnyFeature } from "~/lib/model/geo/AnyFeature";
1414
import type { OSMId } from "~/lib/typing/brands/osmIds";
15+
import OpeningHoursValueListItem from "~/modules/list/OpeningHoursListItem";
1516
import { FullyWheelchairAccessibleIcon } from "~/modules/needs/components/icons/mobility/FullyWheelchairAccessibleIcon";
1617
import { NoDataIcon } from "~/modules/needs/components/icons/mobility/NoDataIcon";
1718
import { NotWheelchairAccessibleIcon } from "~/modules/needs/components/icons/mobility/NotWheelchairAccessibleIcon";
@@ -34,7 +35,7 @@ const TextContainer = styled.div`
3435
3536
3637
`;
37-
const Image = styled(WikidataEntityImage)`
38+
const WikidataImage = styled(WikidataEntityImage)`
3839
height: 3.75rem;
3940
width: 3.75rem;
4041
object-fit: cover;
@@ -113,6 +114,13 @@ export function ListItem({ feature }: { feature: AnyFeature }) {
113114
{placeName}
114115
</PlaceName>
115116
<Category>{categoryName}</Category>
117+
{feature.properties.opening_hours && (
118+
<OpeningHoursValueListItem
119+
osmFeature={feature}
120+
value={feature.properties.opening_hours}
121+
tagKey={"opening_hours"}
122+
/>
123+
)}
116124
<Flex gap="2">
117125
{wheelchair === "yes" && <FullyWheelchairAccessibleIcon />}
118126
{wheelchair === "limited" && <PartiallyWheelchairAccessibleIcon />}
@@ -121,7 +129,7 @@ export function ListItem({ feature }: { feature: AnyFeature }) {
121129
{toilet === "yes" && <FullyWheelchairAccessibleToiletIcon />}
122130
</Flex>
123131
</TextContainer>
124-
<Image feature={feature} verb="P18" />
132+
<WikidataImage feature={feature} verb="P18" />
125133
</Container>
126134
);
127135
}
+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { Flex, Text } from "@radix-ui/themes";
2+
import intersperse from "intersperse";
3+
import { DateTime } from "luxon";
4+
import opening_hours from "opening_hours";
5+
import * as React from "react";
6+
import { t } from "ttag";
7+
import FeatureContext from "~/components/CombinedFeaturePanel/components/FeatureContext";
8+
import { useAdminAreas } from "~/lib/fetchers/osm-api/fetchAdminAreas";
9+
import {
10+
type TypeTaggedOSMFeature,
11+
isOSMFeature,
12+
} from "~/lib/model/geo/AnyFeature";
13+
import { log } from "~/lib/util/logger";
14+
15+
// helper function
16+
function getReadableState(oh: opening_hours) {
17+
const outputs: string[] = [];
18+
const comment = oh.getComment();
19+
if (oh.getUnknown()) {
20+
const maybeOpen = t`Maybe open`;
21+
const maybeOpenBut = t`Maybe open – ${comment}`;
22+
outputs.push(comment ? maybeOpenBut : maybeOpen);
23+
} else {
24+
const state = oh.getState();
25+
outputs.push(state ? t`Now open.` : t`Now closed.`);
26+
if (comment) {
27+
outputs.push(t`(“${comment}”)`);
28+
}
29+
}
30+
return outputs;
31+
}
32+
33+
export default function OpeningHoursValueListItem(props: {
34+
value: string;
35+
tagKey: string;
36+
osmFeature?: TypeTaggedOSMFeature;
37+
}) {
38+
// https://openingh.ypid.de/evaluation_tool/?lng=en
39+
// https://github.com/opening-hours/opening_hours.js
40+
const { value, osmFeature, tagKey } = props;
41+
const feature = React.useContext(FeatureContext);
42+
43+
let lat: number | undefined;
44+
let lon: number | undefined;
45+
46+
if (isOSMFeature(feature)) {
47+
[lon, lat] = feature.geometry.coordinates;
48+
}
49+
const adminAreas = useAdminAreas({ longitude: lon, latitude: lat });
50+
const { featuresByType } = adminAreas;
51+
const country = [
52+
feature?.properties?.["addr:country"],
53+
featuresByType?.country?.properties?.["ISO3166-1:alpha2"],
54+
].find((c) => typeof c === "string");
55+
const state = [
56+
feature?.properties?.["addr:state"],
57+
(featuresByType?.state || featuresByType?.city)?.properties?.state_code,
58+
].find((c) => typeof c === "string");
59+
60+
const { outputs, oh, niceString } = React.useMemo(() => {
61+
try {
62+
const oh = new opening_hours(
63+
value,
64+
lat && lon && country && state
65+
? {
66+
lat,
67+
lon,
68+
address: { country_code: country, state },
69+
}
70+
: null,
71+
{
72+
locale: navigator.language.slice(0, 2),
73+
tag_key: tagKey,
74+
map_value: true,
75+
mode: 2,
76+
warnings_severity: 6,
77+
},
78+
);
79+
const isOpen = oh.getState(); // for current date
80+
const nextChangeDate = oh.getNextChange();
81+
const outputs = getReadableState(oh);
82+
83+
if (typeof nextChangeDate === "undefined")
84+
outputs.push(t`(indefinitely)`);
85+
else {
86+
const isUnknown = oh.getUnknown(nextChangeDate);
87+
const nextChangeDateTime = DateTime.fromJSDate(nextChangeDate);
88+
const nextChangeDateFormatted = nextChangeDateTime.toRelative({
89+
base: DateTime.now(),
90+
});
91+
92+
if (!isUnknown && !isOpen) {
93+
outputs.push(t`Will open ${nextChangeDateFormatted}.`);
94+
} else if (!isUnknown && isOpen) {
95+
outputs.push(t`Will close ${nextChangeDateFormatted}.`);
96+
} else if (isUnknown && !isOpen) {
97+
outputs.push(t`Might open ${nextChangeDateFormatted}.`);
98+
} else if (isUnknown && isOpen) {
99+
outputs.push(t`Might close ${nextChangeDateFormatted}.`);
100+
}
101+
}
102+
return { outputs, oh, niceString };
103+
} catch (e) {
104+
log.error(e);
105+
return { outputs: [] };
106+
}
107+
}, [lat, lon, country, state, value, tagKey]);
108+
109+
const niceLines = oh?.prettifyValue();
110+
const shownValue = ((niceString as string) || "")
111+
.replace(/\bMo\b/g, t`Monday`)
112+
.replace(/\bTu\b/g, t`Tuesday`)
113+
.replace(/\bWe\b/g, t`Wednesday`)
114+
.replace(/\bTh\b/g, t`Thursday`)
115+
.replace(/\bFr\b/g, t`Friday`)
116+
.replace(/\bSa\b/g, t`Saturday`)
117+
.replace(/\bSu\b/g, t`Sunday`)
118+
119+
.replace(/\bJan\b/g, t`January`)
120+
.replace(/\bFeb\b/g, t`February`)
121+
.replace(/\bMar\b/g, t`March`)
122+
.replace(/\bApr\b/g, t`April`)
123+
.replace(/\bMay\b/g, t`May`)
124+
.replace(/\bJun\b/g, t`June`)
125+
.replace(/\bJul\b/g, t`July`)
126+
.replace(/\bAug\b/g, t`August`)
127+
.replace(/\bSep\b/g, t`September`)
128+
.replace(/\bOct\b/g, t`October`)
129+
.replace(/\bNov\b/g, t`November`)
130+
.replace(/\bDec\b/g, t`December`)
131+
132+
.replace(/\bPH\b/g, t`public holiday`)
133+
.replace(/\boff\b/g, t`closed`)
134+
.replace(/\bSH\b/g, t`school holiday`)
135+
.replace(/,/g, ", ");
136+
137+
const shownElements = intersperse(shownValue.split(/;|\|\|/), <br />);
138+
// console.log("outputs: ", outputs);
139+
140+
//
141+
// if (!outputs.length) {
142+
// return <>{shownElements}</>;
143+
// }
144+
145+
const next = oh && DateTime.fromJSDate(oh.getNextChange());
146+
const isOpen = oh?.getState();
147+
const nextChangeIsToday = next && DateTime.local().hasSame(next, "day");
148+
149+
return (
150+
<>
151+
{/*<strong>
152+
<StyledMarkdown inline element="span">
153+
{outputs[0]}
154+
</StyledMarkdown>
155+
</strong>
156+
{outputs.length > 1 && (
157+
<>
158+
&nbsp;
159+
<StyledMarkdown inline element="span">
160+
{outputs.slice(1).join(" ")}
161+
</StyledMarkdown>
162+
</>
163+
)}
164+
&nbsp;
165+
{osmFeature?.properties["opening_hours:url"] && (
166+
<a href={String(osmFeature.properties["opening_hours:url"])}>
167+
{t`See website`}.
168+
</a>
169+
)}
170+
const isOpen = oh.getState(); // for current date
171+
const nextChangeDate = oh.getNextChange();
172+
<div style={{ marginTop: "0.5rem", opacity: 0.8 }}>{shownElements}</div>*/}
173+
{isOpen && next && (
174+
<Flex gap="1" asChild>
175+
<Text size="2">
176+
<Text color="green">
177+
<span>Wahrscheinlich offen</span>
178+
</Text>
179+
<span>bis {next.toFormat("HH:mm")}</span>
180+
</Text>
181+
</Flex>
182+
)}
183+
{!isOpen && next && (
184+
<Flex gap="1" asChild>
185+
<Text size="2">
186+
<Text color="red">
187+
<span>Wahrscheinlich geschlossen</span>
188+
</Text>
189+
{nextChangeIsToday ? (
190+
<span> öffnet um {next.toFormat("HH:mm")}</span>
191+
) : (
192+
<span> öffnet {next.toFormat("ccc HH:mm")}</span>
193+
)}
194+
</Text>
195+
</Flex>
196+
)}
197+
</>
198+
);
199+
}

0 commit comments

Comments
 (0)