Home › Help › Golden-hour and sunset scheduling
Golden-hour and sunset scheduling
When a session type is set to golden hour, ShootCal figures out the sunset (or sunrise) time for your location on the chosen day and builds your shoot times around it. For a sunset type, the prime slot is the one that ends right at sunset, so you are shooting into the best light and finishing as the sun goes down. For a sunrise type, the prime slot starts at sunrise instead.
If you book more than one shoot in a day, ShootCal cascades extra slots away from the sun: sunset types step backward into the afternoon, sunrise types step forward into the morning, each one separated by your break time. As it builds those fallback slots it looks at what is already on your calendar that day and routes around it, so it never offers a time that collides with an existing event.
You do not have to think about any of this. You set the location once, pick whether a type is sunset or sunrise, set the shoot length and break, and the app, the website, and the public booking page all compute the same times the same way. Sun times are estimates good to roughly the minute, and the anchor snaps to the nearest 5 minutes (a 7:18 sunset becomes a 7:20 anchor), because for scheduling that is close enough.
The sun-position math (where sunrise/sunset comes from)
There is no weather API or network call. ShootCal computes the sun event itself from latitude, longitude, and the date, using a port of Mourner's SunCalc algorithm. The exact same math is implemented three times so every surface agrees: once in the native app, once on the backend, and once in the web app. The web and backend versions are deliberate ports of the native one.
The computation: convert the date to a Julian day, derive the solar mean anomaly and ecliptic longitude, get the sun's declination, then solve the hour angle for an altitude of -0.833 degrees (the standard sunrise/sunset altitude that accounts for the solar disc and atmospheric refraction). Sunrise uses the negative hour angle, sunset the positive one. The result is converted back from Julian to a real instant.
A deliberate detail in all three ports: the date is first anchored to local noon before the Julian conversion. The reason: anchoring at midnight in the western hemisphere lands exactly on a rounding boundary in the Julian-day math and ties toward the previous day, which stamped sun times one calendar day early for the entire US audience. Noon sits squarely inside the day's solar-transit cycle, so the bug cannot happen. The backend takes the day's local-noon timestamp as its input; the web rebuilds the date at noon before it does the same calculation.
Polar edge case: if the sun never reaches the threshold (the hour angle has no solution), all three fall back to solar noon (the transit) instead of crashing or returning nothing.
How a session anchors to the sun event
For a sunset type the anchor is the sunset instant, and the cascade packs backward so the prime slot's END lands on sunset. For a sunrise type the anchor is sunrise and the cascade packs forward so the prime slot's START lands on sunrise. This is the core golden-hour behavior: a sunset shoot finishes as the sun sets.
The native app computes the sunset or sunrise time for the chosen sun mode and rounds it to the nearest minute, then snaps that anchor to the nearest 5 minutes. The backend and web snap the raw sun instant straight to 5 minutes (no intermediate minute-rounding step, since the 5-minute snap absorbs the sub-minute difference), so all three land on the same anchor.
The prime flag is set only on the first slot AND only when it lands within 60 seconds of the anchor (the slot's end for sunset, its start for sunrise). So if the very first slot had to be pushed off the sun event by a calendar conflict, it is correctly NOT flagged as the prime golden-hour slot.
The slot cascade and conflict skipping
The cascade builds up to six candidate slots (the same maximum on every surface) in booking-priority order: prime first, then progressively further from the sun.
Sunset (backward) loop: a cursor starts at the sunset anchor. Each iteration proposes a slot one session-length long, ending at the cursor. If that proposed slot overlaps any busy interval on your calendar, the cursor JUMPS: it is reset so the next slot ends one break before the conflicting event begins, then it retries. Otherwise the slot is emitted and the cursor steps back by one session length plus one break. The concrete example: a 7:15 PM event makes the next slot end 6:45 PM, instead of leaving a rigid-grid gap.
Sunrise (forward) loop is the mirror image: the cursor starts at sunrise, proposes a slot one session-length long starting at the cursor, and on conflict jumps to one break after the event ends.
Guards: the loop stops at six slots, when the cursor passes a computed floor or ceiling (the anchor offset by the full six-slot span), or after a hard iteration cap (a guard against pathological conflict layouts). Your busy intervals are sorted by start time first, and the check always takes the earliest overlap.
The result is the first slot in this priority order whose interval does not overlap any busy interval, or nothing if all are taken (in which case the app falls back to its own default start time).
The five session-type modes
A session type's scheduling mode selects how slots are generated:
- Sunset, golden hour ending at sunset, cascade backward.
- Sunrise, golden hour starting at sunrise, cascade forward.
- Studio hours, tile your open-to-close hours, each session one session-length long with a turnover gap between, in chronological order. Ignores the sun. (The built-in "Photo Studio" is 30-minute sessions, 15-minute turnover, 9 AM to 5 PM.)
- Fixed time, a single slot at one clock time (defaults to 1 PM), ignores the sun. The built-in "Real Estate Photography" uses this for even midday interior light.
- All-day, returns no slots (for example a Wedding).
When the native app, backend, and web sync settings between them, each mode is compressed into a single short label ("sunset", "sunrise", "studio", "fixed", or "allday"), and the sunset/sunrise distinction is folded into that label. The parser is tolerant of legacy labels (an older "manual" reads as fixed time, "all-day" variants read as all-day, anything unknown defaults to sun-anchored). All three surfaces branch on these same labels and treat all-day, manual, and empty as "no slots".
Per-day capacity (how many slots get offered)
Sun types stack multiple sessions per day, but the booking page only publishes as many open slots as the type's per-day cap allows, minus what is already booked.
For a sunset or sunrise type, ShootCal resolves your per-day limit (default 1, clamped between 1 and 50), counts that day's already-accepted bookings of the same type, and works out how many remain. It then offers slots until that remaining count hits zero, skipping any slot that is already booked and any slot before the lead-time cutoff. So with the default limit of one, only the prime golden-hour slot is published. Raise it and the next cascade slots open up.
The per-day limit is resolved in order: the type's own explicit value if it carries one, then your studio default, then the underlying legacy global setting, then a built-in default. Because no live type yet carries an explicit value and a missing studio default falls through, an unset per-type field inherits exactly today's global behavior.
Studio and fixed types behave differently: they publish ALL their slots, including booked ones (clearly flagged) so clients can see what is taken. There is also a studio "Fill" mode: unless a studio type has an explicit per-day limit, its limit is effectively unlimited and it fills every available slot.
The submit path re-checks the same limit as a 'day full' gate and re-checks slot overlap, so the picker and the actual booking can never disagree about a day's capacity.
Where the data lives
- Session-type library (native): stored on-device as your encoded list of session types. Decoding is tolerant: missing fields derive from legacy ones so an old library still loads, and an unreadable or missing store reads as empty rather than resurrecting the built-in defaults.
- Sun-relevant per-type settings: the scheduling mode and sun mode, the session length, and the break (for sun types); open hours, close hours, and turnover (for studio); the fixed start time (for fixed). The per-day limit can be left unset, which means inherit.
- Location: your latitude and longitude come from your app settings. On the backend they are read from your saved settings and passed into the slot planner. If there is no location, sun types return zero slots.
- Booking-page state: your global booking config (public token, which calendar, lead time, how far ahead bookings open, which weekdays, your per-day limit, and so on). Studio defaults and session types both live in your saved settings, which sync across your devices. Accepted bookings are what the day-count consults for capacity.
- Timezone: the backend places minute-of-day values and the day's noon in your calendar's timezone, so anchors land on the right local day.
FAQ
Does ShootCal call a weather or sunrise API, or is the sun time computed locally?
It is computed locally, with no network call. All three surfaces (native, backend, and web) run the same SunCalc-derived astronomical formula from latitude, longitude, and the date. It uses a sun altitude of -0.833 degrees, the standard value that accounts for the solar disc and refraction.
For a sunset type, does the shoot start at sunset or end at sunset?
It ends at sunset. The cascade packs backward from the sunset anchor with the prime slot's END pinned to sunset. A sunrise type is the mirror: the prime slot's START is pinned to sunrise and slots cascade forward (later).
How does the cascade avoid double-booking against events already on my calendar?
Before emitting each slot it checks the proposed interval against the day's busy intervals. On a conflict it does not just skip a fixed grid step; it jumps the cursor to one break before the conflicting event's start (sunset) or one break after its end (sunrise), so the next slot butts right up against the existing event instead of leaving a gap. Your busy intervals are sorted by start and the earliest overlap is used.
Why is my sunset slot at 7:20 when the actual sunset is 7:18?
The anchor snaps to the nearest 5 minutes. On native the sun instant is rounded to the nearest minute first and then snapped to 5; on the backend and web the raw sun instant is snapped straight to 5. Either way exact-second precision is not needed for scheduling, so a 7:18 sunset yields a 7:20 anchor.
How many golden-hour slots will a single day offer?
The cascade can generate up to six candidate slots, but the public booking page only publishes as many as your per-day limit allows minus what is already booked. The default limit is one, so by default only the prime golden-hour slot is offered. Raising the limit opens the next backward (or forward) cascade slots.
Why did sun times used to be off by a day, and is that fixed?
Yes, fixed. Anchoring the date at local midnight in the western hemisphere landed on a rounding tie boundary in the Julian-day math and rounded toward the previous day, stamping US sun times one calendar day early. All three ports now anchor the date to local NOON before the Julian conversion, which sits inside the day's solar-transit cycle and removes the tie.
What happens at extreme latitudes where the sun may not rise or set?
The math can have no valid solution for that day (no sunrise or sunset). In that case all three implementations fall back to solar noon (the transit) rather than failing. Also, if no location is configured at all, sun-anchored types simply return no slots.
Do studio and fixed types use any of the sun math?
No. Studio-hours types tile fixed clock hours (open to close, session length plus turnover) and fixed-time types are a single fixed clock start (default 1 PM). Both ignore latitude, longitude, and the sun entirely. Only sun-anchored types (sunset or sunrise) run the sun calculation.
How do the app and the website stay in agreement on slot times?
The slot logic is deliberately ported three times with matching constants and structure across the native app, the backend, and the web. The backend re-applies the same limit and overlap checks the picker used, so the offered slot and the booking commit can never disagree about availability or capacity.
What is the studio 'Fill' behavior versus a sun type's per-day cap?
For a studio type with no explicit per-day limit, the limit becomes effectively unlimited, so every open studio slot can be booked. Sun types instead resolve a concrete per-day limit (default 1, max 50) and publish only that many open slots after subtracting the day's accepted bookings.