Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d9d7608
api: add optional tap selection to DayTimelineView
stefanhp Jul 20, 2026
274539f
feature: add pinch-to-zoom day timeline view
stefanhp Jul 20, 2026
e6909fb
feature: add locale-aware week strip and timeline
stefanhp Jul 20, 2026
f22c027
feature: add drag-to-move/resize for timeline events
stefanhp Jul 21, 2026
4881ae8
build: add cspell configuration for spell checking
stefanhp Jul 21, 2026
5ec098e
fix: correct event y-offset math and grid alignment
stefanhp Jul 21, 2026
82011c4
doc: document drag-to-reschedule feature
stefanhp Jul 21, 2026
c6a66b6
feature: add create, delete, and edit callbacks
stefanhp Jul 22, 2026
95666fc
doc: document edit/create/delete features
stefanhp Jul 22, 2026
32077ac
doc: add CHANGELOG.md
stefanhp Jul 22, 2026
0351bd4
ci: add GitHub Actions CI and lint task
stefanhp Jul 22, 2026
7b6b872
Merge pull request #1 from chronophy/feature/zoomable-and-edit
stefanhp Jul 22, 2026
a5d0256
doc: release CHANGELOG entry for v1.2.0
stefanhp Jul 22, 2026
1a49cfc
Release 1.2.0
stefanhp Jul 22, 2026
f76a588
fix: use wall-clock math to avoid DST drift
stefanhp Jul 23, 2026
fdff489
fix: revert stale reschedule after timeout
stefanhp Jul 23, 2026
b1c9f3f
fix: keep drag live across editingItemID changes
stefanhp Jul 23, 2026
1ea9fb6
fix: align hour labels with grid lines
stefanhp Jul 23, 2026
3af77ea
fix: stop scroll freeze and stale edit state
stefanhp Jul 23, 2026
e566bd4
fix: correct hour-range math for non-zero minutes
stefanhp Jul 23, 2026
a56b7bf
fix: skip event block gesture when non-interactive
stefanhp Jul 23, 2026
7cb61f5
fix: scope overlap column count to connected group
stefanhp Jul 23, 2026
9024953
feature: thread calendar through day timeline
stefanhp Jul 23, 2026
209056f
fix: reset zoom anchor when pinch gesture ends
stefanhp Jul 23, 2026
d9e03b2
fix: use view's calendar for event math
stefanhp Jul 23, 2026
e6a7be0
Merge pull request #4 from chronophy/fix/review-issues
stefanhp Jul 23, 2026
21be8e6
doc: add CHANGELOG entry for 1.3.0
stefanhp Jul 23, 2026
abaceed
Release 1.3.0
stefanhp Jul 23, 2026
73265ba
fix: require double-click for macOS edit mode
stefanhp Jul 23, 2026
1b9d43e
doc: add CHANGELOG entry for 1.3.1
stefanhp Jul 23, 2026
131d6bb
Release 1.3.1
stefanhp Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"version": "0.2",
"language": "en,en-GB",
"words": [
"Codeberg",
"zoomable"
],
"ignorePaths": [
"**/.cspell.json",
".gitignore",
".swift-format"
],
"languageSettings": [
{
"languageId": "swift",
"caseSensitive": true
}
]
}
7 changes: 7 additions & 0 deletions .github/.cspell.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"import": "../.cspell.json",
"words": [
"github",
"nxlabs"
]
}
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
on:
pull_request:
push:

jobs:
version:
uses: nxlabs-ch/shared-workflows/.github/workflows/version.yml@v1.11.1
with:
tag_prefix: ""
secrets:
FF_MERGE_PAT: ${{ secrets.FF_MERGE_PAT }}

test:
needs: [version]
uses: ./.github/workflows/tests.yml

release:
if: github.ref == 'refs/heads/main'
needs: [version, test]
uses: nxlabs-ch/shared-workflows/.github/workflows/deploy-release.yml@v1.11.1
with:
tag: ${{ needs.version.outputs.tag }}
19 changes: 19 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

on:
workflow_call:

jobs:
tests:
runs-on: macos-26
steps:
- uses: actions/checkout@v7

- uses: jdx/mise-action@v4
with:
cache: true

- name: Check formatting
run: mise run lint

- name: Unit Tests
run: mise run tests
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!-- markdownlint-disable MD024 -->
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## [1.3.1] - 2026-07-23

### Fixed

- Entering edit mode on macOS now requires a double-click instead of a single click, preventing accidental drags when clicking through several events

## [1.3.0] - 2026-07-23

### Added

- Custom `calendar`/timezone support for `ZoomableDayTimelineView` and `WeekTimelineView`

### Fixed

- Event positions no longer drift across DST transitions
- Rescheduled events no longer snap back if the host is slow to persist them
- Dragging an event no longer gets interrupted when another event enters edit mode
- Hour labels now align with their grid lines
- Timeline no longer freezes when paging days mid-edit
- Correct hour range when the day starts at a non-zero minute
- Read-only events no longer block taps or scrolling meant for the container
- Overlapping event columns no longer affected by unrelated events elsewhere in the day
- Pinch-to-zoom no longer gets stuck after a cancelled gesture

## [1.2.0] - 2026-07-22

### Added

- Tap-to-select on `DayTimelineView` event blocks
- A zoomable, scrollable full-day timeline (`ZoomableDayTimelineView`) with pinch-to-zoom
- A locale-aware week view (`WeekStripView`/`WeekTimelineView`) with swipe/chevron day navigation
- Editing capabilities for `ZoomableDayTimelineView`/`WeekTimelineView`: drag to move or resize events, create new events by long-press or click-drag, delete events from an in-timeline affordance, and edit-session lifecycle notifications (`onEditStart`/`onEditEnd`)

### Changed

- Event blocks now hit-test via `.position()` instead of `.offset()`, so tap targets align with rendered frames on macOS
- Raised the macOS deployment target to 15

### Fixed

- Event vertical offset math now anchors on start-of-day instead of leaking the reference date's minutes into every event's position

## [1.1.0] - 2025-12-30

### Added

- `ExpandableTimelineContainer`, a compact-to-full-day expand/collapse container with a matched geometry transition

## [1.0.0] - 2025-12-30

Initial release.

### Added

- `TimelineItem` view model and `DayTimelineView`/`CompactTimelineView` for displaying calendar events on an hour grid, with automatic column layout for overlapping events
- `HeightMode` for `CompactTimelineView` (`.flexible` / `.fixed(hours:)`)
- `AccessPromptView`/`.accessRestricted(_:)` for permission-gated content
- `TimelineUIEventKit` module for `EKEvent` ↔ `TimelineItem` conversion
- DocC documentation and a `RenderPreviews` CLI for generating preview screenshots

[1.3.1]: https://github.com/chronophy/timeline-ui/releases/tag/1.3.1
[1.3.0]: https://github.com/chronophy/timeline-ui/releases/tag/1.3.0
[1.2.0]: https://github.com/chronophy/timeline-ui/releases/tag/1.2.0
[1.1.0]: https://github.com/CleanCocoa/timeline-ui/releases/tag/1.1.0
[1.0.0]: https://github.com/CleanCocoa/timeline-ui/releases/tag/1.0.0
103 changes: 99 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ TimelineUI is a SwiftUI component library for displaying calendar timeline visua
mise run build # Build the Swift package
mise run tests # Run package unit tests
mise run format # Format Swift source files
mise run lint # Check formatting without writing anything (what CI runs)
mise run previews # Generate preview PNG images to ./previews/
mise run clean # Clean build artifacts
```
Expand All @@ -21,9 +22,15 @@ mise run clean # Clean build artifacts
- **Package.swift** - Swift Package at root containing the library
- **Sources/TimelineUI/** - Core library with timeline components
- `TimelineItem.swift` - View model for timeline events
- `DayTimelineView.swift` - Full day timeline with hour grid
- `DayTimelineView.swift` - Full day timeline with hour grid, sized to fit available height (no scrolling)
- `ZoomableDayTimelineView.swift` - Full day timeline with pinch-to-zoom; always scrollable, fixed 24h range
- `CompactTimelineView.swift` - Compact 2-3 hour preview
- `TimelineEventBlock.swift` - Individual event block component
- `TimelineEventLayout.swift` - Shared column-layout algorithm for overlapping events
- `WeekStripView.swift` - Locale-aware 7-day week strip toolbar (swipe/chevron week navigation)
- `WeekTimelineView.swift` - `WeekStripView` pinned above a `ZoomableDayTimelineView`
- `ExpandableTimelineContainer.swift` - Compact-to-full-day expand/collapse container
- `TimelineTransitionModifier.swift` - Matched geometry transition used by the expandable container
- `AccessRestrictedModifier.swift` - Blur+overlay for restricted content
- `AccessPromptView.swift` - Standard UI for requesting access
- **Sources/TimelineUIEventKit/** - Optional EventKit integration
Expand All @@ -33,9 +40,13 @@ mise run clean # Clean build artifacts

## Technical Constraints

- Target: iOS 26+, macOS 14+
- Target: iOS 26+, macOS 15+
- Swift 6.2 with modern concurrency
- SwiftUI only - no UIKit dependencies in core library
- SwiftUI only - no UIKit/AppKit dependencies in core library. This rules out reading exact
gesture locations (e.g. pinch-center or two-finger trackpad-swipe position), since
`MagnificationGesture`/`DragGesture` don't expose them in pure SwiftUI - only
`UIPinchGestureRecognizer`/`NSMagnificationGestureRecognizer` do. Work around this with
SwiftUI-only approximations (see `ZoomAnchor`) rather than dropping into UIKit/AppKit interop.
- TimelineUIEventKit links EventKit framework

## API Design
Expand All @@ -50,7 +61,8 @@ TimelineItem(
isAllDay: Bool = false,
color: Color,
location: String? = nil,
isPrimary: Bool = false // Distinguishes "new" from "existing" events
isPrimary: Bool = false, // Distinguishes "new" from "existing" events
isEditable: Bool = false // Allows drag-to-move/resize in ZoomableDayTimelineView
)
```

Expand Down Expand Up @@ -80,6 +92,77 @@ let item = TimelineItem(ekEvent)
let items = ekEvents.asTimelineItems(primaryEventID: selectedEvent.eventIdentifier)
```

### Drag to Reschedule

Items with `isEditable: true` can be moved or resized by dragging in `ZoomableDayTimelineView`
(and `WeekTimelineView`, which is built on it). Supply `onReschedule` to receive the updated item
when a drag ends:

```swift
ZoomableDayTimelineView(
items: items,
onReschedule: { updated in
// Persist updated.startDate / updated.endDate
}
)
```

While an item is being dragged or resized it's in "edit mode": resize handles appear on the
block, and (if `onDelete` is supplied) a delete button does too. Edit mode is a small state
machine (long-press on iOS / double-click on macOS → edit mode → tap elsewhere → view mode), coordinated
across sibling `TimelineEventBlock`s via a shared `editingItemID: UUID?` binding owned by
`ZoomableDayTimelineView`. `initialEditingItemID` seeds it for previews/tests without simulating
a gesture (see `zoomable-day-editing` in `RenderPreviews/main.swift`).

### Create and Delete Events

Long-pressing (iOS) or click-dragging (macOS) empty background in `ZoomableDayTimelineView`
creates a new event — matching each platform's native calendar convention, with a live preview
on macOS. Tapping the delete button shown on an editable item in edit mode deletes it. Both
report back through plain closures; the library never constructs or removes a `TimelineItem`
itself:

```swift
ZoomableDayTimelineView(
items: items,
onDelete: { item in
// Remove item from your data source
},
onCreate: { start, end in
// Insert a new TimelineItem(startDate: start, endDate: end, ...) into your data source
}
)
```

`onDelete` is gated on `isEditable` independently of `onReschedule` — an item can be delete-only
(no drag handles, just the delete button) by supplying `onDelete` without `onReschedule`. Deleting
also exits edit mode: `onEditEnd` (below) always fires immediately before `onDelete`, fired
explicitly in `TimelineEventBlock.deleteButton()` rather than left to the `editingItemID`
`onChange` handler, since that's deferred to the next SwiftUI update cycle and would otherwise
make `onDelete` observably fire before `onEditEnd`.

### Edit Lifecycle Notifications

`onEditStart`/`onEditEnd` fire once each, when an item enters and exits edit mode, as opposed to
`onReschedule` firing once per individual drag. Use them for work that should happen once per
editing session rather than once per drag:

```swift
ZoomableDayTimelineView(
items: items,
onEditStart: { item in
// e.g. snapshot current state for a possible revert
},
onEditEnd: { item in
// e.g. show a single "saved" indicator for the whole editing session
}
)
```

Neither is guaranteed to fire if `TimelineEventBlock` is torn down without an explicit edit-mode
exit (e.g. the host navigates away mid-edit) — `onReschedule`/`onDelete` already covered the data
at that point, so nothing but these notifications is lost.

## Coding Conventions

- Use Swift Testing framework with raw identifiers for test names:
Expand All @@ -88,3 +171,15 @@ let items = ekEvents.asTimelineItems(primaryEventID: selectedEvent.eventIdentifi
```
- Do not add comments unless asked
- After modifying UI components, run `mise run previews` to regenerate preview images
- Extract pure, testable math into a small `enum` colocated in the same file as the view that
uses it (e.g. `ZoomAnchor` in `ZoomableDayTimelineView.swift`, `WeekDateMath` in
`WeekStripView.swift`, `EventPositionMath`/`RescheduleMath` in `TimelineEventBlock.swift`)
instead of embedding it in view code - keeps gesture/layout math covered by Swift Testing
without needing to render views
- Views take data already scoped/filtered by the host app (e.g. `items` for just the selected
day) and expose interaction via stateless closures (`onSelect`) or `Binding`s (`selectedDate`)
rather than fetching or filtering data themselves
- `Sources/RenderPreviews/main.swift`'s `renderView` attaches the `NSHostingView` to a real,
hidden `NSWindow` and pumps the run loop briefly before capturing a bitmap - required for
previewing views with `.onAppear`-driven state (e.g. initial scroll position). A bare
`NSHostingView` never gets a chance to run that state update before the snapshot is taken
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ targets.append(

let package = Package(
name: "TimelineUI",
platforms: [.iOS(.v26), .macOS(.v14)],
platforms: [.iOS(.v26), .macOS(.v15)],
products: [
.library(name: "TimelineUI", targets: ["TimelineUI"]),
.library(name: "TimelineUIEventKit", targets: ["TimelineUIEventKit"]),
Expand Down
Loading