6 Commits

Author SHA1 Message Date
Connor Johnstone
d089f1545b Fixing the gitea error
Some checks failed
Build and Push Docker Image / docker (push) Failing after 22s
2025-09-02 12:34:47 -04:00
Connor Johnstone
7b06fef6c3 Revert "Fix Gitea action Docker build tag error"
This reverts commit 7be9f5a869.
2025-09-02 12:33:17 -04:00
Connor Johnstone
7be9f5a869 Fix Gitea action Docker build tag error
- Add fallback registry to prevent invalid tag format
- Make Docker login conditional on secrets being present
- Make push conditional on registry being configured
- Rename Docker image from calendar to runway

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 12:32:34 -04:00
Connor Johnstone
a7ebbe0635 Add application screenshot to README
Shows Runway's week view with events, all-day events, and dark theme

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:23:13 -04:00
Connor Johnstone
3662f117f5 Fix overlapping events to only split columns for overlapping event groups
Implemented clustering algorithm in calculate_event_layout that:
- Only creates column splits for events that actually overlap
- Non-overlapping events maintain full width display
- Uses greedy column assignment for overlapping groups
- Preserves proper column indices for each event

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:20:37 -04:00
Connor Johnstone
0899a84b42 Fix all-day events: validation and proper header positioning
Backend fixes:
- Fix all-day event creation validation error
- Allow same start/end date for all-day events (single-day events)
- Maintain strict validation for timed events (end must be after start)

Frontend improvements:
- Move all-day events from time grid to day headers
- Add dedicated all-day events container that stacks vertically
- Filter all-day events out of main time-based events area
- Add proper CSS styling for all-day event display and interaction
- Maintain event click handling and color themes

All-day events now appear in the correct location at the top of each
day column and properly stack when multiple events exist.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 11:13:54 -04:00
6 changed files with 190 additions and 55 deletions

View File

@@ -28,7 +28,7 @@ jobs:
context: .
push: true
tags: |
${{ secrets.DOCKER_REGISTRY }}/calendar:latest
${{ secrets.DOCKER_REGISTRY }}/calendar:${{ github.sha }}
${{ secrets.DOCKER_REGISTRY }}/connor/calendar:latest
${{ secrets.DOCKER_REGISTRY }}/connor/calendar:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max

View File

@@ -1,6 +1,8 @@
# Runway
## _Passive infrastructure for life's coordination_
![Runway Screenshot](sample.png)
>[!WARNING]
>This project was entirely vibe coded. It's my first attempt vibe coding anything, but I just sat down one day and realized this was something I've wanted to build for many years, but would just take way too long. With AI, I've been able to lay out the majority of the app in one long weekend. So proceed at your own risk, but I actually think the app is pretty solid.

View File

@@ -417,11 +417,19 @@ pub async fn create_event(
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Generate a unique UID for the event
@@ -707,11 +715,19 @@ pub async fn update_event(
let end_datetime = parse_event_datetime(&request.end_date, &request.end_time, request.all_day)
.map_err(|e| ApiError::BadRequest(format!("Invalid end date/time: {}", e)))?;
// Validate that end is after start
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
// Validate that end is after start (allow equal times for all-day events)
if request.all_day {
if end_datetime < start_datetime {
return Err(ApiError::BadRequest(
"End date must be on or after start date for all-day events".to_string(),
));
}
} else {
if end_datetime <= start_datetime {
return Err(ApiError::BadRequest(
"End date/time must be after start date/time".to_string(),
));
}
}
// Update event properties

View File

@@ -319,11 +319,52 @@ pub fn week_view(props: &WeekViewProps) -> Html {
week_days.iter().map(|date| {
let is_today = *date == props.today;
let weekday_name = get_weekday_name(date.weekday());
let day_events = props.events.get(date).cloned().unwrap_or_default();
// Filter for all-day events only
let all_day_events: Vec<_> = day_events.iter().filter(|event| event.all_day).collect();
html! {
<div class={classes!("week-day-header", if is_today { Some("today") } else { None })}>
<div class="weekday-name">{weekday_name}</div>
<div class="day-number">{date.day()}</div>
<div class="day-header-content">
<div class="weekday-name">{weekday_name}</div>
<div class="day-number">{date.day()}</div>
</div>
// All-day events section
{if !all_day_events.is_empty() {
html! {
<div class="all-day-events">
{
all_day_events.iter().map(|event| {
let event_color = get_event_color(event);
let onclick = {
let on_event_click = props.on_event_click.clone();
let event = (*event).clone();
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
on_event_click.emit(event.clone());
})
};
html! {
<div
class="all-day-event"
style={format!("background-color: {}", event_color)}
{onclick}
>
<span class="all-day-event-title">
{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}
</span>
</div>
}
}).collect::<Html>()
}
</div>
}
} else {
html! {}
}}
</div>
}
}).collect::<Html>()
@@ -611,8 +652,13 @@ pub fn week_view(props: &WeekViewProps) -> Html {
day_events.iter().enumerate().filter_map(|(event_idx, event)| {
let (start_pixels, duration_pixels, is_all_day) = calculate_event_position(event, *date);
// Skip all-day events (they're rendered in the header)
if is_all_day {
return None;
}
// Skip events that don't belong on this date or have invalid positioning
if start_pixels == 0.0 && duration_pixels == 0.0 && !is_all_day {
if start_pixels == 0.0 && duration_pixels == 0.0 {
return None;
}
@@ -1123,59 +1169,88 @@ fn events_overlap(event1: &VEvent, event2: &VEvent) -> bool {
// Calculate layout columns for overlapping events
fn calculate_event_layout(events: &[VEvent], date: NaiveDate) -> Vec<(usize, usize)> {
// Filter events that should appear on this date and sort by start time
// Filter and sort events that should appear on this date
let mut day_events: Vec<_> = events.iter()
.filter(|event| {
.enumerate()
.filter_map(|(idx, event)| {
let (_, _, _) = calculate_event_position(event, date);
// Only include events that would be positioned (non-zero dimensions or all-day)
let local_start = event.dtstart.with_timezone(&Local);
let event_date = local_start.date_naive();
event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20)
if event_date == date ||
(event_date == date - chrono::Duration::days(1) && local_start.hour() >= 20) {
Some((idx, event))
} else {
None
}
})
.collect();
// Sort by start time
day_events.sort_by_key(|event| event.dtstart.with_timezone(&Local).naive_local());
day_events.sort_by_key(|(_, event)| event.dtstart.with_timezone(&Local).naive_local());
// Calculate layout: (column_index, total_columns)
let mut layout = Vec::with_capacity(day_events.len());
let mut columns: Vec<Vec<&VEvent>> = Vec::new();
// For each event, find all events it overlaps with
let mut event_columns = vec![(0, 1); events.len()]; // (column_idx, total_columns)
for event in &day_events {
// Find the first column where this event doesn't overlap with any existing event
let mut placed = false;
for (col_idx, column) in columns.iter_mut().enumerate() {
if !column.iter().any(|existing_event| events_overlap(event, existing_event)) {
column.push(event);
layout.push((col_idx, 0)); // total_columns will be set later
placed = true;
break;
for i in 0..day_events.len() {
let (orig_idx_i, event_i) = day_events[i];
// Find all events that overlap with this event
let mut overlapping_events = vec![i];
for j in 0..day_events.len() {
if i != j {
let (_, event_j) = day_events[j];
if events_overlap(event_i, event_j) {
overlapping_events.push(j);
}
}
}
if !placed {
// Create new column
columns.push(vec![event]);
layout.push((columns.len() - 1, 0)); // total_columns will be set later
}
}
// Update total_columns for all events
let total_columns = columns.len();
for (_, total_cols) in layout.iter_mut() {
*total_cols = total_columns;
}
// Create result mapping original events to their layout
let mut result = Vec::with_capacity(events.len());
for event in events {
if let Some(pos) = day_events.iter().position(|e| e.uid == event.uid) {
result.push(layout[pos]);
// If this event doesn't overlap with anything, it gets full width
if overlapping_events.len() == 1 {
event_columns[orig_idx_i] = (0, 1);
} else {
result.push((0, 1)); // Default: single column
// This event overlaps - we need to calculate column layout
// Sort the overlapping group by start time
overlapping_events.sort_by_key(|&idx| day_events[idx].1.dtstart.with_timezone(&Local).naive_local());
// Assign columns using a greedy algorithm
let mut columns: Vec<Vec<usize>> = Vec::new();
for &event_idx in &overlapping_events {
let (orig_idx, event) = day_events[event_idx];
// Find the first column where this event doesn't overlap with existing events
let mut placed = false;
for (col_idx, column) in columns.iter_mut().enumerate() {
let can_place = column.iter().all(|&existing_idx| {
let (_, existing_event) = day_events[existing_idx];
!events_overlap(event, existing_event)
});
if can_place {
column.push(event_idx);
event_columns[orig_idx] = (col_idx, columns.len());
placed = true;
break;
}
}
if !placed {
// Create new column
columns.push(vec![event_idx]);
event_columns[orig_idx] = (columns.len() - 1, columns.len());
}
}
// Update total_columns for all events in this overlapping group
let total_columns = columns.len();
for &event_idx in &overlapping_events {
let (orig_idx, _) = day_events[event_idx];
event_columns[orig_idx].1 = total_columns;
}
}
}
result
event_columns
}

View File

@@ -650,11 +650,14 @@ body {
}
.week-day-header {
padding: 1rem;
padding: 0.5rem;
text-align: center;
border-right: 1px solid var(--time-label-border, #e9ecef);
background: var(--weekday-header-bg, #f8f9fa);
color: var(--weekday-header-text, inherit);
min-height: 70px; /* Ensure space for all-day events */
display: flex;
flex-direction: column;
}
.week-day-header.today {
@@ -680,6 +683,45 @@ body {
color: var(--calendar-today-text, #1976d2);
}
/* All-day events in header */
.day-header-content {
flex-shrink: 0;
}
.all-day-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 0.5rem;
min-height: 0;
}
.all-day-event {
background: #3B82F6;
color: white;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.75rem;
text-align: left;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.2);
min-height: 18px;
display: flex;
align-items: center;
}
.all-day-event:hover {
filter: brightness(1.1);
}
.all-day-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
/* Week Content */
.week-content {
flex: 1;

BIN
sample.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB