2026-10-21 15:15:20 -07:00
import ByreAPI
import SwiftUI
typealias Schedule = Components . Schemas . Schedule
typealias ScheduleSave = Components . Schemas . ScheduleSave
typealias ScheduleEvery = Components . Schemas . ScheduleEvery
typealias Weekday = Components . Schemas . Weekday
private let weekdayNames : [( Weekday , String , String )] = [
2026-10-21 15:59:24 -07:00
(. mon , "M" , "Monday" ), (. tue , "T" , "Tuesday" ), (. wed , "W" , "Wednesday" ), (. thu , "T" , "Thursday" ), (. fri , "F" , "Friday" ), (. sat , "S" , "Saturday" ), (. sun , "S" , "Sunday" ),
2026-10-21 15:15:20 -07:00
]
private func twoDigits ( _ n : Int ) -> String {
n < 10 ? "0 \( n ) " : " \( n ) "
}
private func clock ( _ hour : Int , _ minute : Int ) -> String {
" \( hour ) : \( twoDigits ( minute )) "
}
private func ordinal ( _ n : Int ) -> String {
let suffix = ( 11. .. 13 ). contains ( n % 100 ) ? "th" : [ 1 : "st" , 2 : "nd" , 3 : "rd" ][ n % 10 ] ?? "th"
return " \( n )\( suffix ) "
}
func scheduleTiming ( _ sc : Schedule ) -> String {
let at = clock ( sc . hour , sc . minute )
switch sc . every {
case . hour :
return "Every hour at : \( twoDigits ( sc . minute )) "
case . day :
return "Daily at \( at ) "
case . week :
let days = Set ( sc . weekdays )
if days . count == 7 {
return "Daily at \( at ) "
}
if days == [. mon , . tue , . wed , . thu , . fri ] {
return "Weekdays at \( at ) "
}
if days == [. sat , . sun ] {
return "Weekends at \( at ) "
}
let names = weekdayNames . filter { days . contains ( $0 . 0 ) }. map ( \ . 2 )
2026-10-21 15:59:24 -07:00
return names . count == 1 ? " \( names [ 0 ] ) s at \( at ) " : " \( names . map { String ( $0 . prefix ( 3 )) }. joined ( separator : ", " )) at \( at ) "
2026-10-21 15:15:20 -07:00
case . month :
return "Monthly on the \( ordinal ( sc . dayOfMonth )) at \( at ) "
}
}
private let nextRunFormat : DateFormatter = {
let f = DateFormatter ()
f . setLocalizedDateFormatFromTemplate ( "EEE MMM d jmm" )
return f
}()
struct ScheduleRows : View {
let project : Project
@ Environment ( Store . self ) private var store
@ Environment ( Overlays . self ) private var overlays
var body : some View {
let list = store . schedules [ project . id ] ?? []
if list . isEmpty {
Text ( "No schedules." )
. foregroundStyle ( Palette . muted )
. padding (. horizontal , 10 )
. padding (. vertical , 6 )
}
VStack ( alignment : . leading , spacing : 0 ) {
ForEach ( list , id : \ . id ) { sc in
HoverRow {
Task {
await editSchedule ( project , sc , store , overlays )
}
} content : {
Text ( sc . title )
. lineLimit ( 1 )
. truncationMode (. tail )
. frame ( maxWidth : . infinity , alignment : . leading )
Text ( scheduleTiming ( sc ))
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
Text ( "next \( nextRunFormat . string ( from : sc . nextRun )) " )
. font ( Fonts . font ( 11 ))
. foregroundStyle ( Palette . muted )
}
2026-10-22 13:38:40 -07:00
. help ( "Runs workflow \( sc . workflow ) " )
2026-10-21 15:15:20 -07:00
}
}
. padding (. bottom , 8 )
}
}
@ MainActor
@ Observable
private final class ScheduleDraft {
var title : String
var every : ScheduleEvery
var weekdays : Set < Weekday >
var hour : Int
var minute : Int
var dayOfMonth : Int
var workflow : String
var prompt : String
var workflows : [ String ] = []
var failure = ""
2026-10-22 13:38:40 -07:00
init ( _ sc : Schedule ?) {
2026-10-21 15:15:20 -07:00
title = sc ?. title ?? ""
every = sc ?. every ?? . week
weekdays = Set ( sc ?. weekdays ?? [. mon ])
hour = sc ?. hour ?? 9
minute = sc ?. minute ?? 0
dayOfMonth = sc . map { $0 . dayOfMonth == 0 ? 1 : $0 . dayOfMonth } ?? 1
workflow = sc ?. workflow ?? ""
prompt = sc ?. prompt ?? ""
}
var valid : Bool {
2026-10-22 13:38:40 -07:00
! trimmed ( title ). isEmpty && ( every != . week || ! weekdays . isEmpty ) && ! workflow . isEmpty
2026-10-21 15:15:20 -07:00
}
var save : ScheduleSave {
ScheduleSave (
title : title ,
every : every ,
weekdays : every == . week ? weekdayNames . map ( \ . 0 ). filter { weekdays . contains ( $0 ) } : nil ,
hour : every == . hour ? 0 : hour ,
minute : minute ,
dayOfMonth : every == . month ? dayOfMonth : nil ,
prompt : prompt ,
2026-10-22 13:38:40 -07:00
workflow : workflow
2026-10-21 15:15:20 -07:00
)
}
}
@ MainActor
func editSchedule ( _ project : Project , _ existing : Schedule ?, _ store : Store , _ overlays : Overlays ) async {
2026-10-22 13:38:40 -07:00
let draft = ScheduleDraft ( existing )
2026-10-21 15:15:20 -07:00
Task {
draft . workflows = ( try ? await store . api . client . listWorkflows (). ok . body . json . map ( \ . name )) ?? []
2026-10-22 13:38:40 -07:00
if draft . workflow . isEmpty , let first = draft . workflows . first {
draft . workflow = first
}
2026-10-21 15:15:20 -07:00
}
while true {
let buttons = existing == nil
? [ ModalButton ( label : "Cancel" , value : "cancel" ), ModalButton ( label : "Create" , value : "save" , primary : true )]
: [ ModalButton ( label : "Delete" , value : "delete" , danger : true ), ModalButton ( label : "Cancel" , value : "cancel" ), ModalButton ( label : "Save" , value : "save" , primary : true )]
let choice = await overlays . showModal ( ScheduleEditor ( draft : draft , project : project ), buttons , inPane : true , canSubmit : { draft . valid })
switch choice {
case "save" :
do {
if let existing {
_ = try await store . api . client . updateSchedule ( path : . init ( scheduleId : existing . id ), body : . json ( draft . save )). ok . body . json
} else {
_ = try await store . api . client . createSchedule ( path : . init ( projectId : project . id ), body : . json ( draft . save )). created . body . json
}
return
} catch {
draft . failure = errorText ( error )
}
case "delete" :
guard let existing , await overlays . showConfirm ([ "Delete the schedule \" \( existing . title ) \" ? Tasks it already created stay." ], action : "Delete" , danger : true , inPane : true ) else {
continue
}
do {
_ = try await store . api . client . deleteSchedule ( path : . init ( scheduleId : existing . id )). noContent
} catch {
overlays . showAlert ( errorText ( error ))
}
return
default :
return
}
}
}
private struct ScheduleEditor : View {
@ Bindable var draft : ScheduleDraft
let project : Project
var body : some View {
VStack ( alignment : . leading , spacing : 12 ) {
2026-10-22 13:38:40 -07:00
Text ( "Schedule a workflow run in \( project . name ) , started from \( project . defaultBranch ) . Times are the server's." )
2026-10-21 15:15:20 -07:00
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
2026-10-22 13:38:40 -07:00
TextField ( "Title" , text : $ draft . title )
2026-10-21 15:15:20 -07:00
. modifier ( FieldStyle ())
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
Text ( "Every" ). foregroundStyle ( Palette . muted )
ChoiceMenu ( value : $ draft . every , options : [(. hour , "hour" ), (. day , "day" ), (. week , "week" ), (. month , "month" )])
if draft . every == . week {
Text ( "on" ). foregroundStyle ( Palette . muted )
HStack ( spacing : 4 ) {
ForEach ( weekdayNames , id : \ . 0 ) { day in
dayToggle ( day )
}
}
}
if draft . every == . month {
Text ( "on the" ). foregroundStyle ( Palette . muted )
ChoiceMenu ( value : $ draft . dayOfMonth , options : ( 1. .. 31 ). map { ( $0 , ordinal ( $0 )) })
}
Text ( "at" ). foregroundStyle ( Palette . muted )
if draft . every == . hour {
ChoiceMenu ( value : $ draft . minute , options : ( 0. .. 59 ). map { ( $0 , ": \( twoDigits ( $0 )) " ) })
} else {
ChoiceMenu ( value : $ draft . hour , options : ( 0. .. 23 ). map { ( $0 , String ( $0 )) })
Text ( ":" ). foregroundStyle ( Palette . muted )
ChoiceMenu ( value : $ draft . minute , options : ( 0. .. 59 ). map { ( $0 , twoDigits ( $0 )) })
}
}
HStack ( alignment : . firstTextBaseline , spacing : 8 ) {
2026-10-22 13:38:40 -07:00
Text ( "Run workflow" ). foregroundStyle ( Palette . muted )
2026-10-21 15:15:20 -07:00
let names = draft . workflow . isEmpty || draft . workflows . contains ( draft . workflow ) ? draft . workflows : [ draft . workflow ] + draft . workflows
2026-10-22 13:38:40 -07:00
ChoiceMenu ( value : $ draft . workflow , options : names . map { ( $0 , $0 ) })
2026-10-21 15:15:20 -07:00
}
2026-10-22 13:38:40 -07:00
Text ( "The workflow's brief, given to every stage (optional)." )
2026-10-21 15:15:20 -07:00
. foregroundStyle ( Palette . muted )
. fixedSize ( horizontal : false , vertical : true )
PromptEditor ( text : $ draft . prompt )
if ! draft . failure . isEmpty {
Text ( draft . failure )
. foregroundStyle ( Palette . danger )
. textSelection (. enabled )
. fixedSize ( horizontal : false , vertical : true )
}
}
. frame ( maxWidth : . infinity , alignment : . leading )
}
private func dayToggle ( _ day : ( Weekday , String , String )) -> some View {
let on = draft . weekdays . contains ( day . 0 )
return Button {
if on {
draft . weekdays . remove ( day . 0 )
} else {
draft . weekdays . insert ( day . 0 )
}
} label : {
Text ( day . 1 )
. font ( Fonts . font ( 11 , weight : on ? 700 : 400 ))
. foregroundStyle ( on ? Palette . text : Palette . muted )
. frame ( width : 20 , height : 20 )
. background ( on ? Palette . accentTint : . clear , in : RoundedRectangle ( cornerRadius : 4 ))
. overlay ( RoundedRectangle ( cornerRadius : 4 ). stroke ( on ? Palette . accent : Palette . border ))
. contentShape ( Rectangle ())
}
. buttonStyle (. plain )
. help ( day . 2 )
. accessibilityLabel ( day . 2 )
}
}