feat(ui): tickets + plan day button, fallback list and prime action

This commit is contained in:
mika kuns
2026-10-01 08:44:21 +02:00
parent 9fc2e26edb
commit e35b054a2e
16 changed files with 242 additions and 12 deletions
+10 -4
View File
@@ -112,10 +112,11 @@
"actionPing": "Ping",
"actionFillMyDay": "Mein Tag füllen",
"actionCustom": "Eigener Prompt",
"actionHint": "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus.",
"actionImportTickets": "Tickets + Tag planen",
"actionHint": "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Tickets + Tag planen“ importiert erst deine zugewiesenen Tickets in die passenden Listen und startet dann die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus.",
"customPromptLabel": "Prompt",
"customPromptPlaceholder": "Der vollständige Prompt für diesen Zeitplan (Pflichtfeld)",
"maxTasksDisabledHint": "Gilt nur für Zeitpläne mit „Mein Tag füllen“.",
"maxTasksDisabledHint": "Gilt nur für Zeitpläne mit „Mein Tag füllen“ oder „Tickets + Tag planen“.",
"promptOverrideLabel": "Prompt-Zusatz",
"promptOverridePlaceholder": "Zusätzliche Anweisungen, die für diesen Zeitplan an den Prime-Prompt angehängt werden (optional)",
"dayMo": "Mo",
@@ -152,7 +153,10 @@
"token": "Personal Access Token",
"tokenSet": "gespeichert",
"testConnection": "Verbindung testen",
"clearToken": "Token entfernen"
"clearToken": "Token entfernen",
"fallbackList": "Fallback-Liste",
"fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.",
"fallbackNone": "(keine)"
},
"inherit": {
"inheritedFromList": "geerbt · Liste",
@@ -285,6 +289,7 @@
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
"planDay": "Tag planen",
"importTicketsAndPlanDay": "Tickets + Tag planen",
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen",
"roadblockReply": {
"placeholder": "Auf den Roadblock antworten…",
@@ -937,7 +942,8 @@
},
"reports": {
"generatingWeekly": "Bericht wird erstellt…",
"runningDailyPrep": "Tagesvorbereitung läuft…"
"runningDailyPrep": "Tagesvorbereitung läuft…",
"runningTicketImport": "Tickets werden importiert, danach Tagesplanung…"
},
"planning": {
"finalizing": "Plan wird finalisiert…",
+10 -4
View File
@@ -112,10 +112,11 @@
"actionPing": "Ping",
"actionFillMyDay": "Fill My Day",
"actionCustom": "Custom prompt",
"actionHint": "Ping only opens the usage window. Fill My Day runs the daily prep. Custom runs your own prompt with the ClaudeDo tools.",
"actionImportTickets": "Tickets + plan day",
"actionHint": "Ping only opens the usage window. Fill My Day runs the daily prep. Tickets + plan day first imports your assigned tickets into the matching lists, then runs the daily prep. Custom runs your own prompt with the ClaudeDo tools.",
"customPromptLabel": "Prompt",
"customPromptPlaceholder": "The full prompt to run for this schedule (required)",
"maxTasksDisabledHint": "Only applies to Fill My Day schedules.",
"maxTasksDisabledHint": "Only applies to Fill My Day and Tickets + plan day schedules.",
"promptOverrideLabel": "Prompt addition",
"promptOverridePlaceholder": "Extra instructions appended to the daily prep prompt for this schedule (optional)",
"dayMo": "Mo",
@@ -152,7 +153,10 @@
"token": "Personal access token",
"tokenSet": "stored",
"testConnection": "Test connection",
"clearToken": "Remove token"
"clearToken": "Remove token",
"fallbackList": "Fallback list",
"fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.",
"fallbackNone": "(none)"
},
"inherit": {
"inheritedFromList": "inherited · List",
@@ -285,6 +289,7 @@
"copyFormattedTip": "Copy title, description and open steps",
"toggleEditPreviewTip": "Toggle edit/preview",
"planDay": "Plan day",
"importTicketsAndPlanDay": "Tickets + plan day",
"prepEmpty": "No prep run today yet — click Plan day",
"roadblockReply": {
"placeholder": "Reply to the roadblock…",
@@ -937,7 +942,8 @@
},
"reports": {
"generatingWeekly": "Generating report…",
"runningDailyPrep": "Running daily prep…"
"runningDailyPrep": "Running daily prep…",
"runningTicketImport": "Importing tickets, then planning the day…"
},
"planning": {
"finalizing": "Finalizing plan…",
@@ -190,6 +190,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
Task<bool> RunDailyPrepNowAsync();
/// <summary>Ticket import, then the daily prep, in one Prime run.</summary>
Task<bool> RunTicketImportNowAsync();
Task RefineTaskAsync(string taskId);
event Action<string>? RefineStartedEvent;
@@ -244,6 +246,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
Task<TicketSettingsDto?> GetTicketSettingsAsync();
/// <summary>Sets the fallback list for imported ticket items; null clears it.</summary>
Task SetTicketFallbackListAsync(string? listId);
Task SetTicketApiBaseUrlAsync(string? baseUrl);
Task SetTicketTokenAsync(string token);
Task ClearTicketTokenAsync();
+6
View File
@@ -504,6 +504,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<bool> RunDailyPrepNowAsync()
=> InvokeTimedAsync<bool>("RunDailyPrepNow", () => _hub.InvokeAsync<bool>("RunDailyPrepNow"));
public Task<bool> RunTicketImportNowAsync()
=> InvokeTimedAsync<bool>("RunTicketImportNow", () => _hub.InvokeAsync<bool>("RunTicketImportNow"));
public Task RefineTaskAsync(string taskId) => InvokeTimedAsync("RefineTask", () => _hub.InvokeAsync("RefineTask", taskId));
public Task ClearMyDayAsync()
@@ -758,6 +761,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<TicketSettingsDto?> GetTicketSettingsAsync()
=> TryInvokeAsync<TicketSettingsDto>("GetTicketSettings");
public Task SetTicketFallbackListAsync(string? listId)
=> InvokeTimedAsync("SetTicketFallbackList", () => _hub.InvokeAsync("SetTicketFallbackList", listId));
public Task SetTicketApiBaseUrlAsync(string? baseUrl)
=> InvokeTimedAsync("SetTicketApiBaseUrl", () => _hub.InvokeAsync("SetTicketApiBaseUrl", baseUrl));
@@ -581,6 +581,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Bind(null, "prep");
IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync();
_ = Prep.RefreshTicketsConfiguredAsync();
}
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
@@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -20,6 +21,9 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _isPrepRunning;
// The button only makes sense with a configured ticket system — otherwise it would just plan the day.
[ObservableProperty] private bool _ticketsConfigured;
// Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning
// stays false until that event arrives, so without this the button click looks like a no-op.
public OperationStatus PrepOperation { get; } = new();
@@ -57,6 +61,7 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
{
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
PlanDayCommand.NotifyCanExecuteChanged();
ImportTicketsAndPlanDayCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsPlanDayEnabled));
};
}
@@ -78,6 +83,25 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
catch { }
}
[RelayCommand(CanExecute = nameof(CanPlanDay))]
private async System.Threading.Tasks.Task ImportTicketsAndPlanDayAsync()
{
using var op = PrepOperation.Begin(Loc.T("ops.reports.runningTicketImport"));
try { await _worker.RunTicketImportNowAsync(); }
catch { }
}
/// <summary>Reads whether the ticket system has a base URL and a token; keeps the last value when the worker is unreachable.</summary>
public async System.Threading.Tasks.Task RefreshTicketsConfiguredAsync()
{
if (!TicketSettingsTabViewModel.FeatureEnabled) { TicketsConfigured = false; return; }
TicketSettingsDto? dto;
try { dto = await _worker.GetTicketSettingsAsync(); }
catch { return; }
if (dto is null) return;
TicketsConfigured = !string.IsNullOrWhiteSpace(dto.ApiBaseUrl) && dto.TokenSet;
}
public async System.Threading.Tasks.Task LoadLastPrepLogIfEmptyAsync()
{
if (IsPrepRunning || PrepLog.Count > 0) return;
@@ -39,7 +39,7 @@ public sealed partial class PrimeClaudeTabViewModel : ViewModelBase
}
private void UpdateAnyFillMyDay() =>
AnyFillMyDay = Rows.Any(r => r.Kind == PrimeActionKind.FillMyDay);
AnyFillMyDay = Rows.Any(r => r.Kind is PrimeActionKind.FillMyDay or PrimeActionKind.ImportTickets);
public async Task LoadAsync()
{
@@ -48,7 +48,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
/// ItemsControl share one selection.</summary>
public string RadioGroup => $"prime-kind-{Id}";
// Three booleans rather than an enum-to-bool converter: RadioButton.IsChecked binds
// One boolean per kind rather than an enum-to-bool converter: RadioButton.IsChecked binds
// TwoWay to a bool, and compiled bindings stay simple.
public bool IsPing
{
@@ -68,6 +68,12 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
set { if (value) Kind = PrimeActionKind.Custom; }
}
public bool IsImportTickets
{
get => Kind == PrimeActionKind.ImportTickets;
set { if (value) Kind = PrimeActionKind.ImportTickets; }
}
public bool ShowPrompt => Kind != PrimeActionKind.Ping;
public string PromptLabel => Kind == PrimeActionKind.Custom
@@ -83,6 +89,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsPing));
OnPropertyChanged(nameof(IsFillMyDay));
OnPropertyChanged(nameof(IsCustom));
OnPropertyChanged(nameof(IsImportTickets));
OnPropertyChanged(nameof(ShowPrompt));
OnPropertyChanged(nameof(PromptLabel));
OnPropertyChanged(nameof(PromptPlaceholder));
@@ -1,10 +1,16 @@
using System.Collections.ObjectModel;
using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
/// <summary>One fallback-list choice; Id null = no fallback.</summary>
public sealed record FallbackListOption(string? Id, string Name);
public sealed partial class TicketSettingsTabViewModel : ViewModelBase
{
/// <summary>
@@ -21,6 +27,7 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
public const bool FeatureEnabled = true;
private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
[ObservableProperty] private string _apiBaseUrl = "";
[ObservableProperty] private string _token = "";
@@ -33,7 +40,17 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
// Never shows the stored token — TicketSettingsDto only ever carries the bool.
public string TokenPlaceholder => TokenSet ? Loc.T("settings.tickets.tokenSet") : "";
public TicketSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public ObservableCollection<FallbackListOption> FallbackLists { get; } = new();
[ObservableProperty] private FallbackListOption? _selectedFallbackList;
// Only a successful load may write the fallback back — otherwise an offline load would clear it.
private bool _fallbackLoaded;
public TicketSettingsTabViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;
}
public async Task LoadAsync()
{
@@ -45,6 +62,17 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
}
ApiBaseUrl = dto.ApiBaseUrl ?? "";
TokenSet = dto.TokenSet;
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = await ctx.Lists.AsNoTracking()
.OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
.Select(l => new FallbackListOption(l.Id, l.Name))
.ToListAsync();
FallbackLists.Clear();
FallbackLists.Add(new FallbackListOption(null, Loc.T("settings.tickets.fallbackNone")));
foreach (var option in lists) FallbackLists.Add(option);
SelectedFallbackList = FallbackLists.FirstOrDefault(o => o.Id == dto.FallbackListId) ?? FallbackLists[0];
_fallbackLoaded = true;
}
public async Task SaveAsync()
@@ -56,6 +84,8 @@ public sealed partial class TicketSettingsTabViewModel : ViewModelBase
Token = "";
TokenSet = true;
}
if (_fallbackLoaded)
await _worker.SetTicketFallbackListAsync(SelectedFallbackList?.Id);
}
[RelayCommand]
@@ -108,7 +108,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
_categories = new List<SettingsCategory>
{
@@ -140,6 +140,11 @@
Command="{Binding Prep.PlanDayCommand}"
IsEnabled="{Binding Prep.IsPlanDayEnabled}"
Content="{loc:Tr details.planDay}"/>
<Button Classes="btn"
Command="{Binding Prep.ImportTicketsAndPlanDayCommand}"
IsEnabled="{Binding Prep.IsPlanDayEnabled}"
IsVisible="{Binding Prep.TicketsConfigured}"
Content="{loc:Tr details.importTicketsAndPlanDay}"/>
<ctl:OperationIndicator Status="{Binding Prep.PrepOperation}"/>
</StackPanel>
</Border>
@@ -362,6 +362,9 @@
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionFillMyDay}"
IsChecked="{Binding IsFillMyDay, Mode=TwoWay}"/>
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionImportTickets}"
IsChecked="{Binding IsImportTickets, Mode=TwoWay}"/>
<RadioButton GroupName="{Binding RadioGroup}"
Content="{loc:Tr settings.prime.actionCustom}"
IsChecked="{Binding IsCustom, Mode=TwoWay}"/>
@@ -770,6 +773,18 @@
<TextBlock Classes="meta" Text="{Binding Tickets.StatusMessage}"
IsVisible="{Binding Tickets.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.tickets.fallbackList}"/>
<ComboBox ItemsSource="{Binding Tickets.FallbackLists}"
SelectedItem="{Binding Tickets.SelectedFallbackList, Mode=TwoWay}"
DisplayMemberBinding="{Binding Name}"
HorizontalAlignment="Left" MinWidth="240"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.tickets.fallbackListHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>