Skip to content

Commit 90b6218

Browse files
fix: refactor availability logic for kathmandu/nepal time
1 parent 235a9a2 commit 90b6218

File tree

12 files changed

+262
-59
lines changed

12 files changed

+262
-59
lines changed

Doc/availableTime.md

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
## Booking Availability: Implementation and Data Flow
2+
3+
### Overview
4+
5+
This document summarizes the end-to-end implementation of the hourly availability feature for venues, covering both frontend and backend components, the data flow between them, potential flaws, and a sample output.
6+
7+
---
8+
9+
## Frontend Implementation
10+
11+
### 1. `useAvailableTimes` Hook
12+
13+
```ts
14+
export interface AvailableTimesVariables {
15+
venueID: number | string;
16+
/** Full RFC3339 timestamp in Kathmandu timezone. */
17+
date: string; // e.g. `2025-06-28T00:00:00+05:45`
18+
}
19+
20+
export const useAvailableTimes = createQuery<
21+
AvailableTimesResponse,
22+
AvailableTimesVariables,
23+
AxiosError<APIError>
24+
>({
25+
queryKey: ["venues", "available-times"],
26+
fetcher: ({ venueID, date }) =>
27+
client.get(`/venues/${venueID}/available-times`, { params: { date } }).then((res) => res.data),
28+
staleTime: 40_000,
29+
refetchInterval: 30_000,
30+
});
31+
```
32+
33+
- Fetches availability slots by passing `date` as full ISO timestamp in Kathmandu time.
34+
- Caching and refetch intervals configured for responsiveness.
35+
36+
### 2. `AvailableTimesScreen` Component
37+
38+
```tsx
39+
const dates = useMemo(() => generateDatesArray(10), []);
40+
const [selectedDate, setSelectedDate] = useState(dates[0].fullDate);
41+
42+
const { data, isLoading, error } = useAvailableTimes({
43+
variables: { venueID: venueId.toString(), date: selectedDate },
44+
});
45+
```
46+
47+
- Generates 10-day window using `generateDatesArray` (returns full ISO in +05:45).
48+
- Uses `FlatList` to render `TimeSlotCard` for each `HourlySlot`.
49+
50+
---
51+
52+
## Backend Implementation
53+
54+
### 1. `availableTimesHandler`
55+
56+
```go
57+
dateStr := r.URL.Query().Get("date")
58+
date, err := time.ParseInLocation(time.RFC3339, dateStr, loc)
59+
loc, _ := time.LoadLocation("Asia/Kathmandu")
60+
dateInKtm := date.In(loc)
61+
dayOfWeek := strings.ToLower(dateInKtm.Weekday().String())
62+
pricingSlots := store.GetPricingSlots(ctx, venueID, dayOfWeek)
63+
bookings := store.GetBookingsForDate(ctx, venueID, date)
64+
// convert bookings to Kathmandu intervals
65+
// generate hourly buckets and check overlaps
66+
```
67+
68+
- Parses full timestamp, converts to Kathmandu to determine `dayOfWeek`.
69+
70+
### 2. Data Access Layer
71+
72+
#### a) `GetPricingSlots`
73+
74+
- Queries `venue_pricing` for given `venueID` and `day_of_week`.
75+
- Returns list of `PricingSlot` with local-times in UTC `time.Time` fields.
76+
77+
#### b) `GetBookingsForDate`
78+
79+
```go
80+
loc, _ := time.LoadLocation("Asia/Kathmandu")
81+
localDate := date.In(loc)
82+
startOfDayLocal := time.Date(localDate.Year(), localDate.Month(), localDate.Day(), 0,0,0,0,loc)
83+
endOfDayLocal := startOfDayLocal.Add(24*time.Hour)
84+
// convert to UTC
85+
startUTC := startOfDayLocal.UTC()
86+
endUTC := endOfDayLocal.UTC()
87+
// SELECT ... WHERE start_time < $1 AND end_time > $2
88+
```
89+
90+
- Computes local day bounds in Kathmandu, converts to UTC for querying `timestamptz` columns.
91+
92+
---
93+
94+
## Data Flow
95+
96+
```text
97+
1. User Interaction:
98+
→ User selects a date via DateSelector
99+
→ selectedDate becomes e.g. 2025-06-28T00:00:00+05:45
100+
101+
2. Frontend Request:
102+
→ useAvailableTimes sends:
103+
GET /venues/{id}/available-times?date=2025-06-28T00:00:00+05:45
104+
105+
3. Handler Parsing:
106+
→ availableTimesHandler parses `dateStr`
107+
→ converts to `dateInKtm`
108+
→ determines `dayOfWeek`
109+
110+
4. Pricing Lookup:
111+
→ Calls GetPricingSlots(venueID, dayOfWeek)
112+
→ returns daily pricing windows
113+
114+
5. Booking Lookup:
115+
→ Calls GetBookingsForDate(ctx, venueID, date)
116+
→ computes local day bounds and queries bookings
117+
118+
6. Slot Generation:
119+
→ Handler breaks pricing windows into 1‑hour buckets
120+
→ Filters out past hours
121+
→ Marks availability by checking overlaps with existing bookings
122+
123+
7. Response:
124+
→ Returns JSON array of HourlySlot objects
125+
→ Format: { start_time, end_time, price_per_hour, available }
126+
→ All timestamps in Kathmandu-local time
127+
```
128+
129+
---
130+
131+
## Server Log Sample
132+
133+
```log
134+
⏰ dateStr: 2025-06-29T00:00:00+05:45
135+
⏰ date after Parse: 2025-06-29 00:00:00 +0545 +0545
136+
⏰ date in Ktm: 2025-06-29 00:00:00 +0545 +0545
137+
⏰ day of week in ktm: sunday
138+
```
139+
140+
- ✅ You're receiving the date string in proper RFC3339 format with Kathmandu offset.
141+
- ✅ You're parsing it using `time.ParseInLocation`, attaching the named location `Asia/Kathmandu`.
142+
- ✅ You're converting and computing the local day correctly.
143+
- ✅ You're querying pricing and bookings properly.
144+
- ✅ You're generating hourly time slot output accurately.
145+
146+
> **Note:** `+0545 +0545` appears twice because Go's internal formatting shows both the numeric offset and the fixed location (if not a named zone).
147+
148+
You can verify the named location with:
149+
150+
```go
151+
fmt.Printf("Time: %s | Location: %s\n", date.Format(time.RFC3339), date.Location())
152+
```
153+
154+
---

cmd/api/api.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ func (app *application) mount() http.Handler {
119119
r.Use(app.AuthTokenMiddleware)
120120
r.Get("/list-venues", app.listVenuesHandler)
121121
r.Get("/favorites", app.listFavoritesHandler)
122-
// Expects URL: /venues/{venueID}/available-times?date=YYYY-MM-DD
123122
r.Get("/{venueID}/available-times", app.availableTimesHandler)
124123
r.Get("/is-venue-owner", app.isVenueOwnerHandler)
125124
r.Post("/", app.createVenueHandler)
@@ -209,7 +208,6 @@ func (app *application) mount() http.Handler {
209208
r.Post("/authentication/reset-password", app.requestResetPasswordHandler)
210209
r.Patch("/authentication/reset-password", app.resetPasswordHandler)
211210

212-
// Public routes
213211
r.Route("/authentication", func(r chi.Router) {
214212
r.Post("/user", app.registerUserHandler)
215213
r.Post("/token", app.createTokenHandler)
@@ -228,7 +226,7 @@ func (app *application) run(mux http.Handler) error {
228226

229227
port := os.Getenv("PORT")
230228
if port == "" {
231-
port = "8080" // Fallback to 8080 if PORT is not set
229+
port = "8080"
232230
}
233231

234232
srv := &http.Server{

cmd/api/auth.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,6 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque
113113

114114
activationURL := fmt.Sprintf("%s/confirm?token=%s", app.config.frontendURL, plainToken)
115115

116-
isProdEnv := app.config.env == "production"
117116
vars := struct {
118117
Username string
119118
ActivationURL string
@@ -123,7 +122,7 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque
123122
}
124123

125124
//send email
126-
status, err := app.mailer.Send(mailer.UserWelcomeTemplate, user.FirstName, user.Email, vars, !isProdEnv)
125+
status, err := app.mailer.Send(mailer.UserWelcomeTemplate, user.FirstName, user.Email, vars)
127126
if err != nil {
128127
app.logger.Errorw("error sending welcome email", "error", err)
129128

@@ -428,8 +427,7 @@ func (app *application) requestResetPasswordHandler(w http.ResponseWriter, r *ht
428427
ResetURL: resetURL,
429428
}
430429

431-
isProdEnv := app.config.env == "production"
432-
status, err := app.mailer.Send(mailer.ResetPasswordTemplate, payload.Email, payload.Email, vars, !isProdEnv)
430+
status, err := app.mailer.Send(mailer.ResetPasswordTemplate, payload.Email, payload.Email, vars)
433431
if err != nil {
434432
app.logger.Errorw("error sending reset password email", "error", err)
435433
app.internalServerError(w, r, err)

cmd/api/booking.go

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,47 +38,87 @@ type HourlySlot struct {
3838
// @Accept json
3939
// @Produce json
4040
// @Param venueID path int true "Venue ID"
41-
// @Param date query string true "Date in YYYY-MM-DD format"
41+
// @Param date query string true "Date in 2025-06-28T00:00:00+05:45 format"
4242
// @Success 200 {array} HourlySlot "Hourly availability"
4343
// @Failure 400 {object} error "Bad Request"
4444
// @Failure 500 {object} error "Internal Server Error"
4545
// @Security ApiKeyAuth
4646
// @Router /venues/{venueID}/available-times [get]
4747
func (app *application) availableTimesHandler(w http.ResponseWriter, r *http.Request) {
48-
// Step 1: Parse venueID from URL path
48+
// Parse venueID from URL path to int64
4949
venueID, err := strconv.ParseInt(chi.URLParam(r, "venueID"), 10, 64)
5050
if err != nil {
5151
app.badRequestResponse(w, r, err)
5252
return
5353
}
5454

55-
// Step 2: Parse `date` from query param in format YYYY-MM-DD
55+
// Parse `date` from query param in format 2025-06-28T00:00:00+05:45
5656
dateStr := r.URL.Query().Get("date")
5757
if dateStr == "" {
5858
app.badRequestResponse(w, r, fmt.Errorf("missing date"))
5959
return
6060
}
61-
date, err := time.Parse("2006-01-02", dateStr)
61+
fmt.Printf("⏰ dateStr: %s\n", dateStr)
62+
63+
// Set the target timezone to Asia/Kathmandu
64+
loc, err := time.LoadLocation("Asia/Kathmandu")
6265
if err != nil {
63-
app.badRequestResponse(w, r, err)
66+
app.internalServerError(w, r, err)
67+
return
68+
}
69+
70+
// Parse into named location so date.Location()==Asia/Kathmandu date will be
71+
// 2025-06-30 00:00:00 +0545 +0545. first +0545 is for offset and second is for location
72+
date, err := time.ParseInLocation(time.RFC3339, dateStr, loc)
73+
if err != nil {
74+
app.badRequestResponse(w, r, fmt.Errorf("invalid date format: %w", err))
6475
return
6576
}
66-
dayOfWeek := strings.ToLower(date.Weekday().String())
77+
78+
fmt.Printf("⏰ date after Parse: %s\n", date)
79+
80+
dateInKtm := date.In(loc)
81+
fmt.Printf("⏰ date in Ktm: %s\n", dateInKtm)
82+
dayOfWeek := strings.ToLower(dateInKtm.Weekday().String())
83+
fmt.Printf("⏰day of week in ktm: %s\n", dayOfWeek)
6784

6885
// Step 3: Load pricing slots and bookings for the venue and the selected date
6986
pricingSlots, err := app.store.Bookings.GetPricingSlots(r.Context(), venueID, dayOfWeek)
7087
if err != nil {
7188
app.internalServerError(w, r, err)
7289
return
7390
}
74-
bookings, err := app.store.Bookings.GetBookingsForDate(r.Context(), venueID, date)
75-
if err != nil {
76-
app.internalServerError(w, r, err)
91+
if len(pricingSlots) == 0 {
92+
app.jsonResponse(w, http.StatusOK, []HourlySlot{}) // no slots for this day
7793
return
7894
}
7995

80-
// Step 4: Set the target timezone to Asia/Kathmandu
81-
loc, err := time.LoadLocation("Asia/Kathmandu")
96+
//Prevents generating duplicate time buckets in output.
97+
// !unique[key]
98+
//This line checks:
99+
//unique[key] looks up the value for that key in the map.
100+
//If it does not exist, Go returns the zero value for the type bool, which is false.
101+
//!false → true
102+
//So !unique[key] becomes true the first time you encounter that key.
103+
unique := make(map[string]bool)
104+
var filtered []store.PricingSlot
105+
106+
for _, ps := range pricingSlots {
107+
key := fmt.Sprintf("%s-%s-%d", ps.StartTime.Format("15:04"), ps.EndTime.Format("15:04"), ps.Price)
108+
if !unique[key] {
109+
unique[key] = true
110+
filtered = append(filtered, ps)
111+
}
112+
}
113+
114+
pricingSlots = filtered
115+
116+
fmt.Println("📊 Pricing slots:")
117+
for _, ps := range pricingSlots {
118+
fmt.Printf("- %s to %s at %d\n", ps.StartTime.Format("15:04"), ps.EndTime.Format("15:04"), ps.Price)
119+
}
120+
121+
bookings, err := app.store.Bookings.GetBookingsForDate(r.Context(), venueID, date)
82122
if err != nil {
83123
app.internalServerError(w, r, err)
84124
return
@@ -109,6 +149,14 @@ func (app *application) availableTimesHandler(w http.ResponseWriter, r *http.Req
109149

110150
// Step 7: Generate hourly time slots and mark availability
111151
var out []HourlySlot
152+
// Round current time to the next full hour in Kathmandu timezone
153+
now := time.Now().In(loc)
154+
if now.Minute() > 0 || now.Second() > 0 || now.Nanosecond() > 0 {
155+
now = now.Truncate(time.Hour).Add(time.Hour)
156+
} else {
157+
now = now.Truncate(time.Hour)
158+
}
159+
112160
for _, ps := range pricingSlots {
113161
// Convert pricing slot times into the selected date in Nepal timezone
114162
slotStart := time.Date(date.Year(), date.Month(), date.Day(),
@@ -120,6 +168,10 @@ func (app *application) availableTimesHandler(w http.ResponseWriter, r *http.Req
120168
for t := slotStart; !t.Add(time.Hour).After(slotEnd); t = t.Add(time.Hour) {
121169
tEnd := t.Add(time.Hour)
122170

171+
if t.Before(now) {
172+
continue
173+
}
174+
123175
out = append(out, HourlySlot{
124176
StartTime: t,
125177
EndTime: tEnd,

0 commit comments

Comments
 (0)