Skip to content

More Hours functionality

Hannah Zulueta edited this page Aug 11, 2026 · 1 revision

"More Hours" Feature — Technical Documentation (Working Draft)

Status: Draft for issue #2578 — "Document current 'More Hours' functionality/logic." This is a temporary, local working file. Once reviewed, its content should be copied into the team's Google Doc / wiki per the issue's action items, and this file deleted.

Feature origin: PR #2046 ("2005 stakeholder preview redesign") introduced the accordion-style "More Hours" UI and the nearest-open-time logic. The underlying hours data model and DB schema predate that PR (see Data Model below). Parent issue: #2178 — "Improve how hours are shown on search results," which this documentation is meant to unblock.


1. TL;DR

  • An organization's hours are stored as an array of intervals (stakeholder.hours), each with a day of week, an optional "which week of the month" recurrence, and an open/close time.
  • "Open Now" is computed independently, twice, once per component (StakeholderPreview.tsx and StakeholderDetails.tsx), both delegating to one shared function, stakeholdersDaysHours().
  • The "More Hours" accordion (list of all intervals, sorted) and the "Open Now" chip use two different algorithms for matching "which week of the month is it," and those two algorithms disagree for any recurring interval that isn't anchored to Sunday. This is documented in detail in §7 — it's the most actionable finding for design/research.
  • Hours spanning midnight (e.g., 10pm–2am) are not correctly handled — see §8.1.

2. Data Model

2.1 Database

  • Original source table: stakeholder_schedule (one row per open/close interval).

  • Since migration 1590892356169_stakeholder-add-categoryIds-hours.js, the stakeholder_schedule rows for each org are denormalized into a single array column, stakeholder.hours, of a Postgres composite type:

    -- server/db/demo-db/foodoasis.sql:133-138
    CREATE TYPE public.stakeholder_hours AS (
        week_of_month integer,
        day_of_week   character varying,
        open          character varying,
        close         character varying
    );

    This eliminates a join/subquery on every stakeholder fetch. A trigger (on_insert_or_update_stakeholder) keeps stakeholder_log (the audit table) in sync whenever stakeholder.hours changes.

  • create_stakeholder / update_stakeholder stored procedures (server/db/stored_procs/create_stakeholder.sql, update_stakeholder.sql) accept hours_array stakeholder_hours[] directly.

2.2 Field semantics

Field Type Meaning
day_of_week string Three-letter, capitalized short weekday: "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" — matches the JS Intl/Date.toLocaleString(..., { weekday: "short" }) output, and the admin dropdown values (client/src/components/Admin/OpenTimeForm.tsx:61-70).
week_of_month integer Recurrence: 0 = every week, 1–4 = 1st/2nd/3rd/4th occurrence of that weekday in the month, -1 = last occurrence of that weekday in the month. Admin UI label mapping in OpenTimeForm.tsx:72-80 ("Every", "First", "Second", "Third", "Fourth", "Last").
open / close string 24-hour "HH:mm:ss" text (e.g. "09:00:00"), written by the admin DesktopTimePicker via dayjs(...).format("HH:mm:ss") (OpenTimeForm.tsx:216, :303). Stored as character varying, so nothing in the DB enforces this format — it's a convention followed by the writer code.

2.3 Client-side type

// client/src/types/Stakeholder.ts:9-14
export interface Hour {
  close: string;
  day_of_week: string;
  open: string;
  week_of_month: number;
}

A stakeholder can have any number of Hour entries — this is what "multi-interval" means: e.g. a pantry open Tuesday 9–12 and Tuesday 2–5 is two separate Hour rows with the same day_of_week, or a food bank open every week plus an extra distribution on the "Last Saturday" is two rows with different week_of_month.

2.4 Data flow

flowchart LR
  A["stakeholder_schedule table<br/>(one row per interval)"] -->|denormalize trigger,<br/>migration 1590892356169| B["stakeholder.hours<br/>stakeholder_hours[] column"]
  B -->|GET /stakeholders API| C["Hour[] on the client<br/>types/Stakeholder.ts"]
  C --> D["stakeholdersDaysHours()<br/>StakeholderPreview.tsx"]
  C --> E["hoursSort() + render list<br/>'More Hours' accordion"]
  D --> F["Open Now / Closing soon /<br/>Opening soon chips"]
  E --> G["isCurrentDayAndWeek()<br/>bolds today's row"]
  E --> H["nextOpenTime()<br/>accordion header text"]
  I["Admin edit form<br/>OpenTimeForm.tsx"] -->|"hours[]" via Formik,<br/>HH:mm:ss strings| B
Loading

3. Where the logic lives

File Role
client/src/helpers/index.ts Pure helper functions: sorting, date/week-of-month math, next-open-time text, minutes-to-open/close.
client/src/components/FoodSeeker/SearchResults/StakeholderPreview/StakeholderPreview.tsx Defines stakeholdersDaysHours() (the actual "is this org open right now" check) and renders the search-result card's hours UI (chip + accordion). This is the single source of truth for "Open Now" — StakeholderDetails.tsx imports the same function rather than reimplementing it.
client/src/components/FoodSeeker/SearchResults/StakeholderDetails/StakeholderDetails.tsx Renders the same Open Now / Closing soon / Opening soon / hours list UI for the full detail panel, reusing stakeholdersDaysHours (imported from StakeholderPreview.tsx:49).
client/src/components/Admin/OrganizationEdit/BusinessHours.tsx Admin-facing tab wrapper: "Confirm Hours" checkbox, "Allow Walk-Ins" checkbox, hours notes textarea, and embeds OpenTimeForm.
client/src/components/Admin/OpenTimeForm.tsx The actual multi-row editor: a Formik FieldArray where each row picks a repeat interval, a day, an open time, and a close time. Supports add/remove/duplicate ("copy") of rows.
server/migrations/1590892356169_stakeholder-add-categoryIds-hours.js + server/db/demo-db/foodoasis.sql DB schema / denormalization.

4. Core helper functions (client/src/helpers/index.ts)

Function Lines Purpose
getDayTimeNow() 331-337 Returns [dayNow, timeTime] as ["MON", "09:30AM"]-style strings, used only to seed the search filter "day/time" dropdowns (FilterPanel.tsx, AdvancedFilters.tsx) — not the "Open Now" logic on a card.
getDayOfWeekNum(str) 339-351 Maps a lowercase weekday string to 0(Sun)–6(Sat).
getLastWeekdayInMonth(year, month, dow) 353-361 Returns the calendar date (as a formatted string) of the last occurrence of dow in month.
getNthWeekdayInMonth(day_of_week, week_of_month, date) 363-380 Returns the actual Date of the Nth (or last) occurrence of a weekday in the month containing date. This is the mathematically correct implementation of "Nth weekday of month" — contrast with getCurrentWeekOfMonth() below.
getCurrentWeekOfMonth() 382-389 Returns which Sunday-anchored calendar-week block today falls in (0 for the days before the first Sunday of the month, incrementing at each Sunday). This does not equal "which occurrence of today's specific weekday this is" except when today is a Sunday — see §7.
isLastWeekOfMonth() 391-406 True if today falls within the final 7 days of the month.
isCurrentDayAndWeek(timeZone, hour) 418-434 Used to bold today's row in the "More Hours" list. True if hour.day_of_week matches today AND (week_of_month === 0, or matches getCurrentWeekOfMonth(), or is -1 and isLastWeekOfMonth()). Inherits the week-of-month mismatch from getCurrentWeekOfMonth().
dayOfWeek(str) (private) 436-445 Re-orders weekdays so "today" sorts first, for use by hoursSort.
standardTime(timeStr) 447-459 Converts "HH:mm:ss" → "h:mm am/pm" display text (e.g. "09:00:00" → "9:00 am"). Note: the "am" branch parses timeStr.substring(0, 5) instead of substring(0, 2) like the other branches — functionally harmless (parseInt stops at the :), but inconsistent and confusing to read.
hoursSort(a, b) 461-514 Comparator used everywhere hours are displayed. Sorts so that entries relevant to this week come first, then by weekday (today first), then by open time. See §6.
nextOpenTime(sortedHours, timeZone) 516-569 Produces the text after "Open " in the accordion header — either a day name, an explicit time range, or a future date. Uses getCurrentWeekOfMonth() internally, so it inherits the same mismatch.
calculateTimeDifference(t1, t2) (private) 571-585 t1 - t2 in minutes, by parsing both as same-day UTC timestamps (1970-01-01T{time}Z). No midnight-rollover handling.
calculateMinutesToClosing(hours, tz) 587-593 Minutes until hours[0].close. Only looks at the first element of whatever array it's given.
calculateMinutesToOpening(hours, tz) 595-605 Minutes until hours[0].open.
isAlmostClosed(hours, tz) 607-614 True if calculateMinutesToClosing <= 60.
isAlmostOpen(hours, tz) 616-623 True if calculateMinutesToOpening <= 60.
getNextDateForDay(...) 288-329 Converts a day/time filter selection into a target Date. Appears unused — no caller found anywhere in client/src outside its own definition. Likely dead code left over from an earlier version of the "Open Time" search filter (see openTimeFilter state in appReducer.tsx, which is instead consumed directly as raw day/time strings by useOrganizationBests.ts). Worth a quick check with whoever last touched the open-time filter before deleting it.

5. "Open Now" computation — stakeholdersDaysHours()

Defined and exported from StakeholderPreview.tsx:69-109 (not helpers/index.ts), and reused by StakeholderDetails.tsx (import { stakeholdersDaysHours } from "../StakeholderPreview/StakeholderPreview", line 49). This function is the actual "is this organization open right now" check — everything downstream (the green "Open Now" chip, the "Closing in N minutes" / "Opening in N minutes" banners) is derived from its result.

flowchart TD
  Start(["stakeholdersDaysHours(stakeholder, tenantTimeZone, dateToCheck)"]) --> A["currentDayOfWeek = short weekday of dateToCheck in tenantTimeZone"]
  A --> B["dayOccurrenceInMonth = ceil(dayOfMonth / 7)"]
  B --> C["currentTime = HH:mm:ss of dateToCheck in tenantTimeZone"]
  C --> D["filter stakeholder.hours where:<br/>• day_of_week matches today<br/>• currentTime is within [open, close)<br/>• week_of_month is 0, OR equals dayOccurrenceInMonth,<br/>&nbsp;&nbsp;OR is -1 AND this is the weekday's last occurrence this month"]
  D --> E{"any matches?"}
  E -->|"yes"| F["return the matching Hour[]<br/>→ isOpenFlag = true → 'Open Now' chip"]
  E -->|"no"| G["return undefined<br/>→ isOpenFlag = false"]
Loading

Key implementation details:

  • dayOccurrenceInMonth = Math.ceil(dayOfMonth / 7) (StakeholderPreview.tsx:81-83) is a different, and notably simpler and more correct, way of computing "which occurrence of this weekday in the month" than getCurrentWeekOfMonth() in helpers/index.ts — see §7.
  • isLastOccurrenceInMonth() (StakeholderPreview.tsx:47-66) is a helper local to this file: it checks whether today + 7 days rolls into a different calendar month, as a proxy for "is today in the last 7-day stretch of this month." It's the local, Date-based cousin of isLastWeekOfMonth() in helpers/index.ts — two separate implementations of essentially the same "is this the last week of the month" check.
  • The comparison currentTime >= todaysHours.open && currentTime < todaysHours.close is a plain string comparison on "HH:mm:ss" text. This works because that format sorts lexicographically the same as chronologically within a single day, but breaks for overnight intervals — see §8.1.
  • If an org has multiple matching intervals at once (e.g. overlapping hours, or a duplicate entry), all of them are returned, but only hours[0] (first in the stored/filtered order, not necessarily the one closing soonest) is later used by calculateMinutesToClosing/calculateMinutesToOpening — see §8.3.

6. Sorting the hours list — hoursSort()

Used everywhere the full hours list is displayed (stakeholder.hours.sort(hoursSort) in both StakeholderPreview.tsx:342,398 and StakeholderDetails.tsx), so "today's hours" and "this week's recurring specials" surface near the top instead of a plain Sunday–Saturday listing.

Order of comparison:

  1. Which entries apply "this week" — using the same getCurrentWeekOfMonth() / isLastWeekOfMonth() primitives as §7 — sort first.
  2. Among entries that are not this week, sort by numeric proximity to the current week (nearest upcoming/most recent recurrence first), normalizing -1 (last week) to 5 so it sorts after 4.
  3. Within the same "this week" bucket, sort by weekday, using a day ordering rotated so today is first (dayOfWeek(), helpers/index.ts:436-445) — e.g. if today is Wednesday, the order becomes Wed, Thu, Fri, Sat, Sun, Mon, Tue.
  4. Finally, sort by open time (string comparison) to keep multiple same-day intervals in chronological order.

Because this comparator shares getCurrentWeekOfMonth() with isCurrentDayAndWeek, the ordering can also misplace non-Sunday recurring entries relative to what a user would expect — same root cause as §7.


7. Verified inconsistency: two disagreeing "week of month" algorithms

This is the most concrete, reproducible finding, directly relevant to #2178 ("hours display is incomplete and misleading").

The codebase computes "which occurrence of a weekday within the current month is today" two different ways, and they disagree for most weekdays:

  • dayOccurrenceInMonth in StakeholderPreview.tsx:81-83 — Math.ceil(dayOfMonth / 7). This drives the "Open Now" chip (via stakeholdersDaysHours).
  • getCurrentWeekOfMonth() in helpers/index.ts:382-389 — Math.ceil((dayOfMonth - 1 - dayOfWeekIndex) / 7), i.e. which Sunday-to-Saturday calendar block today falls in. This drives isCurrentDayAndWeek (bolding a row in the "More Hours" list) and nextOpenTime (the "Open [when]" accordion header text).

Math.ceil(dayOfMonth / 7) is mathematically correct for "Nth occurrence of a specific fixed weekday" (each occurrence is exactly 7 days apart, so 1–7 → 1st, 8–14 → 2nd, 15–21 → 3rd, 22–28 → 4th, 29–31 → 5th, regardless of what day the month starts on). getCurrentWeekOfMonth() instead measures calendar weeks anchored to Sunday, independent of which weekday is being matched — it only happens to agree with the correct value when the target weekday is Sunday.

Reproduced empirically (Node.js, both functions extracted verbatim from the source): comparing all four weekly recurrences (1st–4th) for every weekday, across 4 years of months:

day_of_week Agreement rate
Sun 85% (164/192)
Mon 67% (128/192)
Tue 54% (104/192)
Wed 40% (76/192)
Thu 25% (48/192)
Fri 13% (24/192)
Sat 0% (0/192)

Concrete example: January 1, 2024 is a Monday. An org configured with an interval { day_of_week: "Mon", week_of_month: 1, open: "09:00:00", close: "17:00:00" } ("First Monday of the month") would, on that date:

  • Show the green "Open Now" chip correctly (dayOccurrenceInMonth = ceil(1/7) = 1, matches).
  • Not have that row bolded in the "More Hours" list (getCurrentWeekOfMonth() returns 0 on Jan 1, 2024, which ≠ 1).
  • Show a generic "Open Monday" header via nextOpenTime instead of recognizing today as the matching occurrence, because the week_of_month > 0 branch (helpers/index.ts:528-531) continues past this entry when getCurrentWeekOfMonth() !== week_of_month.

Practical impact: any stakeholder using a non-Sunday "First/Second/Third/Fourth [Weekday]" recurring hours entry (a real, supported use case in the admin editor's "Repeat…" dropdown) can end up with the "Open Now" chip and the "More Hours" list visibly disagreeing about whether today is a match. This is a good candidate to flag to design/research under #2178, since it directly affects the trustworthiness of the hours display they're trying to improve.


8. Other edge cases and limitations

8.1 Midnight-crossing hours not supported

Both the "Open Now" check (stakeholdersDaysHours, currentTime >= open && currentTime < close) and calculateTimeDifference (new Date("1970-01-01T" + time + "Z") for both times, then subtract) treat open/close as same-day times. An interval like open: "22:00:00", close: "02:00:00" (a pantry open 10pm–2am) is never correctly recognized as "open" after midnight — e.g. checking at 11:30pm gives currentTime >= open (true) but currentTime < close ("23:30:00" < "02:00:00" is false as a string comparison), so the org would incorrectly show as closed. The admin editor's close-time picker (OpenTimeForm.tsx:263-267) even sets minTime={open}, which would prevent an admin from entering a close time earlier than the open time in the first place — so this may be more of a data-entry constraint than a live bug today, but it means genuinely overnight hours cannot be represented at all.

8.2 Missing/empty hours data

Handled gracefully: if stakeholder.hours is null/undefined/empty, stakeholdersDaysHours short-circuits to undefined (stakeholder.hours && ..., StakeholderPreview.tsx:88), isOpenFlag becomes false, and the UI falls back to a plain "No hours on record" message (StakeholderPreview.tsx:437-444) instead of any chip.

8.3 Overlapping intervals: only the first one drives the countdown

stakeholdersDaysHours returns all intervals that match "today, right now" (there's no dedup or overlap detection). But calculateMinutesToClosing / calculateMinutesToOpening (and therefore the "Closing in N minutes" / "Opening in N minutes" banners) only ever look at hours[0] — whichever matching interval happens to be first in the unsorted API array order, not necessarily the one that's actually closing soonest. Two overlapping/duplicate intervals for the same day could produce a misleading countdown.

8.4 Single timezone per site instance

tenantTimeZone comes from useSiteContext() — one timezone for the whole FoodOasis deployment, applied uniformly to every stakeholder's hours. This matches FoodOasis's single-region deployment model (e.g. one instance per metro area) and isn't a bug, but it's worth calling out explicitly: there's no per-stakeholder timezone override anywhere in this logic.

8.5 Default (collapsed) view omits which day is shown — ties directly into #2178

The collapsed accordion header only shows "Open " + nextOpenTime(...) (StakeholderPreview.tsx:339-345), which per §4 can render as just a weekday name ("Open Monday") or a bare time range ("Open 9:00 am - 5:00 pm") with no day label attached in the time-range case (nextOpenTime, helpers/index.ts:542-546) — this matches the exact complaint in #2178 ("Default view omits the day of the week, making hours ambiguous"). The full day-by-day breakdown only appears once the accordion is expanded.

8.6 Minor code-quality notes (not user-facing, but relevant if this logic gets touched)

  • isCurrentDayAndWeek (helpers/index.ts:431) uses a bitwise & on Number(...)-coerced booleans instead of &&. It happens to produce the same result for 0/1 values, but reads as a bug at a glance.
  • standardTime (helpers/index.ts:456-457) parses substring(0, 5) on one branch vs. substring(0, 2) on the others — harmless in practice (parseInt stops at the first non-digit), but inconsistent.
  • Two separate "is this the last week of the month" implementations exist: isLastWeekOfMonth() (helpers/index.ts:391-406) and isLastOccurrenceInMonth() (StakeholderPreview.tsx:47-66), using different techniques (day-of-month arithmetic vs. +7 days month-rollover check). They likely agree in practice but are two things to keep in sync if either is ever changed.
  • getNextDateForDay() (helpers/index.ts:288-329) appears to be dead code — see §4.

9. Admin editing UI

BusinessHours.tsx (tab wrapper) + OpenTimeForm.tsx (the actual editor):

  • Each hours entry is one row in a Formik FieldArray (OpenTimeForm.tsx:99-390): Repeat interval dropdown (Every/First/Second/Third/Fourth/Last → week_of_month 0/1/2/3/4/-1), Day dropdown (Sun–Sat), Open time and Close time pickers (MUI DesktopTimePicker, formatted to "HH:mm:ss" on change).
  • Rows can be added ("Add Hours" button), removed (✕ icon), or duplicated ("copy" icon, insert(index + 1, hour) — clones the row's values as a starting point for a similar entry, e.g. a second interval on the same day).
  • Close-time picker enforces minTime={open} client-side, and separately tracks "minTime" / "equalTime" validation errors to show "Closing time must be after opening time" / "...must be different from opening time" — so admins cannot currently save an overnight (close-time-before-open-time) interval, reinforcing §8.1.
  • No validation exists against overlapping intervals (e.g. two rows both covering Tuesday 9am–5pm) — an admin can save duplicates or overlaps freely.
  • confirmedHours is a separate checkbox/flag (unrelated to the open/close computation itself — appears to be a data-quality confirmation flag).

10. Open questions / follow-ups for research & design (per issue action items)

  1. Decide whether §7 (disagreeing week-of-month algorithms) should become its own bug ticket, or be folded into the #2178 redesign work directly (since #2178 is already touching this display).
  2. Confirm whether overnight hours (§8.1) are a real-world need for any current or prospective stakeholders before investing in fixing it — right now the admin UI structurally prevents entering them.
  3. Decide whether getNextDateForDay() (§4) is truly dead code and safe to remove, or whether it was meant to be wired into the "Open Time" search filter and never finished.
  4. This document still needs to be: turned into diagrams for a non-engineering audience (the mermaid diagrams above are a starting point), copied into a Google Doc/wiki page, linked from #2578 and #2178, and presented to the research/design team — per the original issue's action items.

Clone this wiki locally