Skip to content

Commit 43aaa93

Browse files
release: v0.18.0 — multiple availability windows per event type
Add support for multiple time windows per day (e.g. 09:00-12:00 + 13:00-17:00) to create lunch breaks or custom schedules. Dynamic UI with add/remove buttons, backward-compatible with existing single-window event types. Also fixes post-action redirects across all dashboard pages. Closes #5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e14acf9 commit 43aaa93

5 files changed

Lines changed: 219 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
6767
| Host identity on bookings | 0.17.1 | Avatar, name, and title shown on slot picker for individual bookings |
6868
| Team link search UX | 0.17.1 | Search + pill selection for team members with avatar previews |
6969
| Matrix-style initials | 0.17.1 | Two-letter avatar fallback (first+last name initials) across all pages |
70+
| Multiple availability windows | 0.18.0 | Define morning + afternoon slots with lunch breaks (multiple time windows per event type) |
7071

7172
## [Unreleased]
7273

74+
## [0.18.0] - 2026-03-11
75+
76+
### Added
77+
78+
- **Multiple availability windows per event type** — define separate time blocks (e.g. 09:00–12:00 + 13:00–17:00) to create lunch breaks or custom schedules. Dynamic "Add time window" UI with add/remove buttons. Backward-compatible with existing single-window event types. Closes #5.
79+
80+
### Fixed
81+
82+
- **Post-action redirects go to correct dashboard page** — creating/deleting team links, event types, bookings, and sources now redirect to their respective page instead of the overview
83+
7384
## [0.17.6] - 2026-03-11
7485

7586
### Fixed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "calrs"
3-
version = "0.17.6"
3+
version = "0.18.0"
44
edition = "2021"
55
description = "A fast, self-hostable scheduling platform. Like Cal.com, but written in Rust."
66
license = "AGPL-3.0"

src/web/mod.rs

Lines changed: 150 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,40 @@ fn format_time_from_dt(dt_str: &str) -> String {
239239
}
240240
}
241241

242+
/// Parse availability windows from the form.
243+
/// Supports new `avail_windows` format ("09:00-12:00,13:00-17:00") with fallback to
244+
/// legacy single `avail_start`/`avail_end` pair. Returns at least one window.
245+
fn parse_avail_windows(
246+
windows_str: Option<&str>,
247+
legacy_start: Option<&str>,
248+
legacy_end: Option<&str>,
249+
) -> Vec<(String, String)> {
250+
if let Some(ws) = windows_str.filter(|s| !s.trim().is_empty()) {
251+
let parsed: Vec<(String, String)> = ws
252+
.split(',')
253+
.filter_map(|w| {
254+
let parts: Vec<&str> = w.trim().splitn(2, '-').collect();
255+
if parts.len() == 2
256+
&& NaiveTime::parse_from_str(parts[0], "%H:%M").is_ok()
257+
&& NaiveTime::parse_from_str(parts[1], "%H:%M").is_ok()
258+
{
259+
Some((parts[0].to_string(), parts[1].to_string()))
260+
} else {
261+
None
262+
}
263+
})
264+
.collect();
265+
if !parsed.is_empty() {
266+
return parsed;
267+
}
268+
}
269+
// Fallback to legacy single window
270+
vec![(
271+
legacy_start.unwrap_or("09:00").to_string(),
272+
legacy_end.unwrap_or("17:00").to_string(),
273+
)]
274+
}
275+
242276
pub fn create_router(pool: SqlitePool, data_dir: PathBuf, secret_key: [u8; 32]) -> Router {
243277
let mut env = Environment::new();
244278
env.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
@@ -1263,9 +1297,10 @@ struct EventTypeForm {
12631297
location_type: Option<String>, // "link", "phone", "in_person", "custom"
12641298
location_value: Option<String>,
12651299
// Availability schedule
1266-
avail_days: Option<String>, // comma-separated: "1,2,3,4,5"
1267-
avail_start: Option<String>, // "09:00"
1268-
avail_end: Option<String>, // "17:00"
1300+
avail_days: Option<String>, // comma-separated: "1,2,3,4,5"
1301+
avail_start: Option<String>, // legacy: "09:00"
1302+
avail_end: Option<String>, // legacy: "17:00"
1303+
avail_windows: Option<String>, // "09:00-12:00,13:00-17:00"
12691304
// Group (optional)
12701305
group_id: Option<String>,
12711306
// Calendar selection (comma-separated IDs)
@@ -1435,23 +1470,28 @@ async fn create_event_type(
14351470

14361471
// Create availability rules
14371472
let avail_days = form.avail_days.as_deref().unwrap_or("1,2,3,4,5");
1438-
let avail_start = form.avail_start.as_deref().unwrap_or("09:00");
1439-
let avail_end = form.avail_end.as_deref().unwrap_or("17:00");
1473+
let windows = parse_avail_windows(
1474+
form.avail_windows.as_deref(),
1475+
form.avail_start.as_deref(),
1476+
form.avail_end.as_deref(),
1477+
);
14401478

14411479
for day_str in avail_days.split(',') {
14421480
if let Ok(day) = day_str.trim().parse::<i32>() {
14431481
if (0..=6).contains(&day) {
1444-
let rule_id = uuid::Uuid::new_v4().to_string();
1445-
let _ = sqlx::query(
1446-
"INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
1447-
)
1448-
.bind(&rule_id)
1449-
.bind(&et_id)
1450-
.bind(day)
1451-
.bind(avail_start)
1452-
.bind(avail_end)
1453-
.execute(&state.pool)
1454-
.await;
1482+
for (ws, we) in &windows {
1483+
let rule_id = uuid::Uuid::new_v4().to_string();
1484+
let _ = sqlx::query(
1485+
"INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
1486+
)
1487+
.bind(&rule_id)
1488+
.bind(&et_id)
1489+
.bind(day)
1490+
.bind(ws)
1491+
.bind(we)
1492+
.execute(&state.pool)
1493+
.await;
1494+
}
14551495
}
14561496
}
14571497
}
@@ -1513,30 +1553,41 @@ async fn edit_event_type_form(
15131553
};
15141554

15151555
// Get current availability rules
1516-
let rules: Vec<(i32, String, String)> = sqlx::query_as(
1517-
"SELECT day_of_week, start_time, end_time FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week LIMIT 1",
1556+
let all_rules: Vec<(i32, String, String)> = sqlx::query_as(
1557+
"SELECT day_of_week, start_time, end_time FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week, start_time",
15181558
)
15191559
.bind(&et_id)
15201560
.fetch_all(&state.pool)
15211561
.await
15221562
.unwrap_or_default();
15231563

1524-
let all_rules: Vec<(i32,)> = sqlx::query_as(
1525-
"SELECT DISTINCT day_of_week FROM availability_rules WHERE event_type_id = ? ORDER BY day_of_week",
1526-
)
1527-
.bind(&et_id)
1528-
.fetch_all(&state.pool)
1529-
.await
1530-
.unwrap_or_default();
1564+
let avail_days: String = {
1565+
let mut days: Vec<i32> = all_rules.iter().map(|(d, _, _)| *d).collect();
1566+
days.sort();
1567+
days.dedup();
1568+
days.iter()
1569+
.map(|d| d.to_string())
1570+
.collect::<Vec<_>>()
1571+
.join(",")
1572+
};
15311573

1532-
let avail_days: String = all_rules
1574+
// Collect distinct time windows (preserving order)
1575+
let mut windows: Vec<(String, String)> = Vec::new();
1576+
for (_, s, e) in &all_rules {
1577+
let pair = (s.clone(), e.clone());
1578+
if !windows.contains(&pair) {
1579+
windows.push(pair);
1580+
}
1581+
}
1582+
let avail_windows: String = windows
15331583
.iter()
1534-
.map(|(d,)| d.to_string())
1584+
.map(|(s, e)| format!("{}-{}", s, e))
15351585
.collect::<Vec<_>>()
15361586
.join(",");
1537-
let (avail_start, avail_end) = rules
1587+
// Legacy fields for backward compat
1588+
let (avail_start, avail_end) = windows
15381589
.first()
1539-
.map(|(_, s, e)| (s.clone(), e.clone()))
1590+
.cloned()
15401591
.unwrap_or_else(|| ("09:00".to_string(), "17:00".to_string()));
15411592

15421593
// Get user's calendars (is_busy=1) for calendar selection
@@ -1599,6 +1650,7 @@ async fn edit_event_type_form(
15991650
form_avail_days => avail_days,
16001651
form_avail_start => avail_start,
16011652
form_avail_end => avail_end,
1653+
form_avail_windows => avail_windows,
16021654
form_reminder_minutes => reminder_min.unwrap_or(0),
16031655
error => "",
16041656
sidebar => sidebar_context(&auth_user, "event-types"),
@@ -1692,23 +1744,28 @@ async fn update_event_type(
16921744
.await;
16931745

16941746
let avail_days = form.avail_days.as_deref().unwrap_or("1,2,3,4,5");
1695-
let avail_start = form.avail_start.as_deref().unwrap_or("09:00");
1696-
let avail_end = form.avail_end.as_deref().unwrap_or("17:00");
1747+
let windows = parse_avail_windows(
1748+
form.avail_windows.as_deref(),
1749+
form.avail_start.as_deref(),
1750+
form.avail_end.as_deref(),
1751+
);
16971752

16981753
for day_str in avail_days.split(',') {
16991754
if let Ok(day) = day_str.trim().parse::<i32>() {
17001755
if (0..=6).contains(&day) {
1701-
let rule_id = uuid::Uuid::new_v4().to_string();
1702-
let _ = sqlx::query(
1703-
"INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
1704-
)
1705-
.bind(&rule_id)
1706-
.bind(&et_id)
1707-
.bind(day)
1708-
.bind(avail_start)
1709-
.bind(avail_end)
1710-
.execute(&state.pool)
1711-
.await;
1756+
for (ws, we) in &windows {
1757+
let rule_id = uuid::Uuid::new_v4().to_string();
1758+
let _ = sqlx::query(
1759+
"INSERT INTO availability_rules (id, event_type_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
1760+
)
1761+
.bind(&rule_id)
1762+
.bind(&et_id)
1763+
.bind(day)
1764+
.bind(ws)
1765+
.bind(we)
1766+
.execute(&state.pool)
1767+
.await;
1768+
}
17121769
}
17131770
}
17141771
}
@@ -3110,6 +3167,7 @@ fn render_event_type_form_error(
31103167
form_avail_days => form.avail_days.as_deref().unwrap_or("1,2,3,4,5"),
31113168
form_avail_start => form.avail_start.as_deref().unwrap_or("09:00"),
31123169
form_avail_end => form.avail_end.as_deref().unwrap_or("17:00"),
3170+
form_avail_windows => form.avail_windows.as_deref().unwrap_or(""),
31133171
error => error,
31143172
sidebar => sidebar_context(auth_user, "event-types"),
31153173
impersonating => impersonating,
@@ -7590,4 +7648,54 @@ mod tests {
75907648
let tz = parse_guest_tz(None);
75917649
let _ = tz;
75927650
}
7651+
7652+
// --- parse_avail_windows tests ---
7653+
7654+
#[test]
7655+
fn parse_avail_windows_single_window() {
7656+
let w = parse_avail_windows(Some("09:00-17:00"), None, None);
7657+
assert_eq!(w, vec![("09:00".to_string(), "17:00".to_string())]);
7658+
}
7659+
7660+
#[test]
7661+
fn parse_avail_windows_multiple_windows() {
7662+
let w = parse_avail_windows(Some("09:00-12:00,13:00-17:00"), None, None);
7663+
assert_eq!(
7664+
w,
7665+
vec![
7666+
("09:00".to_string(), "12:00".to_string()),
7667+
("13:00".to_string(), "17:00".to_string()),
7668+
]
7669+
);
7670+
}
7671+
7672+
#[test]
7673+
fn parse_avail_windows_legacy_fallback() {
7674+
let w = parse_avail_windows(None, Some("08:00"), Some("16:00"));
7675+
assert_eq!(w, vec![("08:00".to_string(), "16:00".to_string())]);
7676+
}
7677+
7678+
#[test]
7679+
fn parse_avail_windows_empty_string_uses_legacy() {
7680+
let w = parse_avail_windows(Some(""), Some("10:00"), Some("18:00"));
7681+
assert_eq!(w, vec![("10:00".to_string(), "18:00".to_string())]);
7682+
}
7683+
7684+
#[test]
7685+
fn parse_avail_windows_invalid_times_ignored() {
7686+
let w = parse_avail_windows(Some("09:00-12:00,bad-data,13:00-17:00"), None, None);
7687+
assert_eq!(
7688+
w,
7689+
vec![
7690+
("09:00".to_string(), "12:00".to_string()),
7691+
("13:00".to_string(), "17:00".to_string()),
7692+
]
7693+
);
7694+
}
7695+
7696+
#[test]
7697+
fn parse_avail_windows_defaults_when_none() {
7698+
let w = parse_avail_windows(None, None, None);
7699+
assert_eq!(w, vec![("09:00".to_string(), "17:00".to_string())]);
7700+
}
75937701
}

templates/event_type_form.html

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,12 @@ <h2>Availability</h2>
8080
<input type="hidden" name="avail_days" id="avail_days" value="{{ form_avail_days }}">
8181
</div>
8282

83-
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="form-grid-2">
84-
<div class="form-group">
85-
<label for="avail_start">Start time</label>
86-
<input type="time" id="avail_start" name="avail_start" value="{{ form_avail_start }}">
87-
</div>
88-
<div class="form-group">
89-
<label for="avail_end">End time</label>
90-
<input type="time" id="avail_end" name="avail_end" value="{{ form_avail_end }}">
91-
</div>
83+
<div class="form-group">
84+
<label>Time windows</label>
85+
<div id="time-windows" style="display: flex; flex-direction: column; gap: 0.5rem;"></div>
86+
<button type="button" id="add-window-btn" style="margin-top: 0.5rem; padding: 0.375rem 0.75rem; border: 1px dashed var(--border); border-radius: var(--radius); background: transparent; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; transition: all 0.15s ease;">+ Add time window</button>
87+
<input type="hidden" name="avail_windows" id="avail_windows" value="{{ form_avail_windows }}">
88+
<span class="hint" style="font-size: 0.8rem; color: var(--text-muted); display: block; margin-top: 0.25rem;">Add multiple windows to create breaks (e.g. morning + afternoon with lunch break).</span>
9289
</div>
9390
</div>
9491

@@ -172,6 +169,56 @@ <h2>Notifications</h2>
172169
});
173170
});
174171

172+
// --- Time windows ---
173+
const windowsContainer = document.getElementById('time-windows');
174+
const windowsHidden = document.getElementById('avail_windows');
175+
const addBtn = document.getElementById('add-window-btn');
176+
177+
function syncWindows() {
178+
const rows = windowsContainer.querySelectorAll('.time-window-row');
179+
const vals = [];
180+
rows.forEach(row => {
181+
const s = row.querySelector('.tw-start').value;
182+
const e = row.querySelector('.tw-end').value;
183+
if (s && e) vals.push(s + '-' + e);
184+
});
185+
windowsHidden.value = vals.join(',');
186+
}
187+
188+
function addWindow(start, end) {
189+
const row = document.createElement('div');
190+
row.className = 'time-window-row';
191+
row.style.cssText = 'display:flex;align-items:center;gap:0.5rem;';
192+
row.innerHTML =
193+
'<input type="time" class="tw-start" value="' + start + '" style="flex:1;padding:0.5rem 0.625rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);color:var(--text);font-family:inherit;font-size:0.925rem;">' +
194+
'<span style="color:var(--text-muted);">to</span>' +
195+
'<input type="time" class="tw-end" value="' + end + '" style="flex:1;padding:0.5rem 0.625rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);color:var(--text);font-family:inherit;font-size:0.925rem;">' +
196+
'<button type="button" class="tw-remove" style="padding:0.25rem 0.5rem;border:none;background:transparent;color:var(--text-muted);cursor:pointer;font-size:1.1rem;line-height:1;" title="Remove">&times;</button>';
197+
row.querySelector('.tw-start').addEventListener('change', syncWindows);
198+
row.querySelector('.tw-end').addEventListener('change', syncWindows);
199+
row.querySelector('.tw-remove').addEventListener('click', () => {
200+
row.remove();
201+
// Keep at least one window
202+
if (!windowsContainer.querySelector('.time-window-row')) addWindow('09:00', '17:00');
203+
syncWindows();
204+
});
205+
windowsContainer.appendChild(row);
206+
syncWindows();
207+
}
208+
209+
addBtn.addEventListener('click', () => addWindow('', ''));
210+
211+
// Initialize from saved value or legacy fields
212+
const saved = windowsHidden.value.trim();
213+
if (saved) {
214+
saved.split(',').forEach(w => {
215+
const [s, e] = w.split('-');
216+
if (s && e) addWindow(s, e);
217+
});
218+
} else {
219+
addWindow('{{ form_avail_start }}', '{{ form_avail_end }}');
220+
}
221+
175222
// Auto-generate slug from title
176223
{% if not editing %}
177224
const titleEl = document.getElementById('title');

0 commit comments

Comments
 (0)