Skip to content

Commit 735a561

Browse files
committed
feat: add timezone-aware opening hours
1 parent da368c7 commit 735a561

File tree

11 files changed

+1017
-537
lines changed

11 files changed

+1017
-537
lines changed

app/pages/index.vue

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,30 @@
11
<script setup lang="ts">
2+
import { toZonedTime } from 'date-fns-tz'
3+
import OpeningHours from 'opening_hours'
4+
5+
function getOpeningHoursStatus(
6+
expression: string | null | undefined,
7+
timezone: string | null | undefined,
8+
reference: Date = new Date(),
9+
): { isOpen: boolean, message: string, nextChange: Date | null } {
10+
if (!expression || !timezone)
11+
return { isOpen: false, message: 'Hours unavailable', nextChange: null }
12+
13+
try {
14+
const localDate = toZonedTime(reference, timezone)
15+
const schedule = new OpeningHours(expression.trim())
16+
17+
const isOpen = schedule.getState(localDate)
18+
const nextChange = schedule.getNextChange(localDate) || null
19+
const message = isOpen ? 'Open now' : 'Closed'
20+
21+
return { isOpen, message, nextChange }
22+
}
23+
catch {
24+
return { isOpen: false, message: 'Hours unavailable', nextChange: null }
25+
}
26+
}
27+
228
const searchQuery = ref('')
329
const selectedCategories = ref<CategoryResponse[]>([])
430
const filters = ref<string[]>([])
@@ -14,6 +40,16 @@ const { data: categories, refresh: refreshCategories } = useFetch('/api/categori
1440
const { data: locations } = await useFetch('/api/search', {
1541
query: {
1642
q: searchQuery,
43+
openNow: computed(() => filters.value.includes('open_now') ? 'true' : undefined),
44+
},
45+
transform: (data) => {
46+
return data?.map(location => ({
47+
...location,
48+
hoursStatus: getOpeningHoursStatus(
49+
location.openingHours,
50+
location.timezone,
51+
),
52+
})) ?? []
1753
},
1854
})
1955
@@ -142,6 +178,25 @@ function removeCategory(categoryId: string) {
142178
{{ location.address }}
143179
</p>
144180

181+
<p
182+
v-if="location.openingHours"
183+
text="f-sm"
184+
:class="location.hoursStatus.isOpen ? 'text-green-600' : 'text-neutral-500'"
185+
font-medium mb-0 mt-6
186+
>
187+
<template v-if="location.hoursStatus.nextChange">
188+
{{ location.hoursStatus.message }} · {{ location.hoursStatus.isOpen ? 'Closes' : 'Opens' }} at
189+
<NuxtTime
190+
:datetime="location.hoursStatus.nextChange"
191+
hour="numeric"
192+
minute="2-digit"
193+
/>
194+
</template>
195+
<template v-else>
196+
{{ location.hoursStatus.message }}
197+
</template>
198+
</p>
199+
145200
<div flex="~ wrap gap-6" f-mt-sm>
146201
<span
147202
v-for="cat in location.categories.slice(0, 3)"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE "locations" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint
2+
UPDATE "locations" SET "timezone" = 'Europe/Zurich' WHERE "timezone" IS NULL;--> statement-breakpoint
3+
ALTER TABLE "locations" ALTER COLUMN "timezone" SET NOT NULL;--> statement-breakpoint
4+
ALTER TABLE "locations" ADD COLUMN IF NOT EXISTS "opening_hours" text;
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
{
2+
"id": "29e19560-cc74-4406-9ce1-6f05a6687aef",
3+
"prevId": "6b68c759-0cf3-434e-b465-3b299ee5c3e2",
4+
"version": "7",
5+
"dialect": "postgresql",
6+
"tables": {
7+
"public.categories": {
8+
"name": "categories",
9+
"schema": "",
10+
"columns": {
11+
"id": {
12+
"name": "id",
13+
"type": "text",
14+
"primaryKey": true,
15+
"notNull": true
16+
},
17+
"name": {
18+
"name": "name",
19+
"type": "text",
20+
"primaryKey": false,
21+
"notNull": true
22+
},
23+
"icon": {
24+
"name": "icon",
25+
"type": "text",
26+
"primaryKey": false,
27+
"notNull": true
28+
},
29+
"created_at": {
30+
"name": "created_at",
31+
"type": "timestamp",
32+
"primaryKey": false,
33+
"notNull": false
34+
}
35+
},
36+
"indexes": {},
37+
"foreignKeys": {},
38+
"compositePrimaryKeys": {},
39+
"uniqueConstraints": {},
40+
"policies": {},
41+
"checkConstraints": {},
42+
"isRLSEnabled": false
43+
},
44+
"public.location_categories": {
45+
"name": "location_categories",
46+
"schema": "",
47+
"columns": {
48+
"location_uuid": {
49+
"name": "location_uuid",
50+
"type": "text",
51+
"primaryKey": false,
52+
"notNull": true
53+
},
54+
"category_id": {
55+
"name": "category_id",
56+
"type": "text",
57+
"primaryKey": false,
58+
"notNull": true
59+
},
60+
"created_at": {
61+
"name": "created_at",
62+
"type": "timestamp",
63+
"primaryKey": false,
64+
"notNull": false
65+
}
66+
},
67+
"indexes": {
68+
"location_idx": {
69+
"name": "location_idx",
70+
"columns": [
71+
{
72+
"expression": "location_uuid",
73+
"isExpression": false,
74+
"asc": true,
75+
"nulls": "last"
76+
}
77+
],
78+
"isUnique": false,
79+
"concurrently": false,
80+
"method": "btree",
81+
"with": {}
82+
},
83+
"category_idx": {
84+
"name": "category_idx",
85+
"columns": [
86+
{
87+
"expression": "category_id",
88+
"isExpression": false,
89+
"asc": true,
90+
"nulls": "last"
91+
}
92+
],
93+
"isUnique": false,
94+
"concurrently": false,
95+
"method": "btree",
96+
"with": {}
97+
}
98+
},
99+
"foreignKeys": {
100+
"location_categories_location_uuid_locations_uuid_fk": {
101+
"name": "location_categories_location_uuid_locations_uuid_fk",
102+
"tableFrom": "location_categories",
103+
"tableTo": "locations",
104+
"columnsFrom": [
105+
"location_uuid"
106+
],
107+
"columnsTo": [
108+
"uuid"
109+
],
110+
"onDelete": "cascade",
111+
"onUpdate": "no action"
112+
},
113+
"location_categories_category_id_categories_id_fk": {
114+
"name": "location_categories_category_id_categories_id_fk",
115+
"tableFrom": "location_categories",
116+
"tableTo": "categories",
117+
"columnsFrom": [
118+
"category_id"
119+
],
120+
"columnsTo": [
121+
"id"
122+
],
123+
"onDelete": "cascade",
124+
"onUpdate": "no action"
125+
}
126+
},
127+
"compositePrimaryKeys": {
128+
"location_categories_location_uuid_category_id_pk": {
129+
"name": "location_categories_location_uuid_category_id_pk",
130+
"columns": [
131+
"location_uuid",
132+
"category_id"
133+
]
134+
}
135+
},
136+
"uniqueConstraints": {},
137+
"policies": {},
138+
"checkConstraints": {},
139+
"isRLSEnabled": false
140+
},
141+
"public.locations": {
142+
"name": "locations",
143+
"schema": "",
144+
"columns": {
145+
"uuid": {
146+
"name": "uuid",
147+
"type": "text",
148+
"primaryKey": true,
149+
"notNull": true
150+
},
151+
"name": {
152+
"name": "name",
153+
"type": "text",
154+
"primaryKey": false,
155+
"notNull": true
156+
},
157+
"address": {
158+
"name": "address",
159+
"type": "text",
160+
"primaryKey": false,
161+
"notNull": true
162+
},
163+
"location": {
164+
"name": "location",
165+
"type": "geometry(point)",
166+
"primaryKey": false,
167+
"notNull": true
168+
},
169+
"rating": {
170+
"name": "rating",
171+
"type": "double precision",
172+
"primaryKey": false,
173+
"notNull": false
174+
},
175+
"photo": {
176+
"name": "photo",
177+
"type": "text",
178+
"primaryKey": false,
179+
"notNull": false
180+
},
181+
"gmaps_place_id": {
182+
"name": "gmaps_place_id",
183+
"type": "text",
184+
"primaryKey": false,
185+
"notNull": true
186+
},
187+
"gmaps_url": {
188+
"name": "gmaps_url",
189+
"type": "text",
190+
"primaryKey": false,
191+
"notNull": true
192+
},
193+
"website": {
194+
"name": "website",
195+
"type": "text",
196+
"primaryKey": false,
197+
"notNull": false
198+
},
199+
"source": {
200+
"name": "source",
201+
"type": "varchar(20)",
202+
"primaryKey": false,
203+
"notNull": true
204+
},
205+
"timezone": {
206+
"name": "timezone",
207+
"type": "text",
208+
"primaryKey": false,
209+
"notNull": true
210+
},
211+
"opening_hours": {
212+
"name": "opening_hours",
213+
"type": "text",
214+
"primaryKey": false,
215+
"notNull": false
216+
},
217+
"updated_at": {
218+
"name": "updated_at",
219+
"type": "timestamp",
220+
"primaryKey": false,
221+
"notNull": false
222+
},
223+
"created_at": {
224+
"name": "created_at",
225+
"type": "timestamp",
226+
"primaryKey": false,
227+
"notNull": false
228+
}
229+
},
230+
"indexes": {
231+
"location_spatial_idx": {
232+
"name": "location_spatial_idx",
233+
"columns": [
234+
{
235+
"expression": "location",
236+
"isExpression": false,
237+
"asc": true,
238+
"nulls": "last"
239+
}
240+
],
241+
"isUnique": false,
242+
"concurrently": false,
243+
"method": "gist",
244+
"with": {}
245+
}
246+
},
247+
"foreignKeys": {},
248+
"compositePrimaryKeys": {},
249+
"uniqueConstraints": {
250+
"locations_gmaps_place_id_unique": {
251+
"name": "locations_gmaps_place_id_unique",
252+
"nullsNotDistinct": false,
253+
"columns": [
254+
"gmaps_place_id"
255+
]
256+
}
257+
},
258+
"policies": {},
259+
"checkConstraints": {},
260+
"isRLSEnabled": false
261+
}
262+
},
263+
"enums": {},
264+
"schemas": {},
265+
"sequences": {},
266+
"roles": {},
267+
"policies": {},
268+
"views": {},
269+
"_meta": {
270+
"columns": {},
271+
"schemas": {},
272+
"tables": {}
273+
}
274+
}

database/migrations/meta/_journal.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
"when": 1759310582635,
1616
"tag": "0001_fine_boomer",
1717
"breakpoints": true
18+
},
19+
{
20+
"idx": 2,
21+
"version": "7",
22+
"when": 1759482276567,
23+
"tag": "0002_opening_hours",
24+
"breakpoints": true
1825
}
1926
]
2027
}

database/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const locations = pgTable('locations', {
2020
gmapsUrl: text('gmaps_url').notNull(),
2121
website: text('website'),
2222
source: varchar('source', { length: 20, enum: ['naka', 'bluecode'] }).notNull(),
23+
timezone: text('timezone').notNull(),
24+
openingHours: text('opening_hours'),
2325
updatedAt: timestamp('updated_at').$defaultFn(() => new Date()).$onUpdateFn(() => new Date()),
2426
createdAt: timestamp('created_at').$defaultFn(() => new Date()),
2527
}, table => [

0 commit comments

Comments
 (0)