WorkTracker je postavený na Clean Architecture se čtyřmi vrstvami a plugin systémem. Tento dokument popisuje, jak jsou vrstvy uspořádané, jak komunikují a jaké vzory projekt používá.
Pokud tě zajímá jak napsat vlastní plugin, jdi rovnou na plugin-development.md. Pokud hledáš setup, build a testy, viz developer-guide.md.
- Diagram vrstev
- Vrstvy a odpovědnosti
- Dependency flow
- Presentation: CLI, WPF, Avalonia
- Plugin systém
- Klíčové vzory
- Datový tok: příklad odeslání worklogu
┌─────────────────────────────────────────────────────────────────────┐
│ Presentation │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ WorkTracker │ │ WorkTracker │ │ WorkTracker.Avalonia │ │
│ │ .CLI │ │ .WPF │ │ (cross-platform desktop) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────────┘ │
│ │ │ │ │
│ │ ┌───────┴─────────────────────┘ │
│ │ │ │
│ │ ┌──────▼───────────────────────────────────────────┐ │
│ │ │ WorkTracker.UI.Shared │ │
│ │ │ - ViewModely (CommunityToolkit.Mvvm) │ │
│ │ │ - Orchestrátory (koordinují služby mezi UI) │ │
│ │ │ - ISettingsService, ILocalizationService │ │
│ │ │ - IPomodoroService │ │
│ │ └──────────────────┬───────────────────────────────┘ │
└─────────────────────────────┬─┼─────────────────────────────────────┘
│ │
┌─────────────────────────────▼─▼─────────────────────────────────────┐
│ Application │
│ - IWorkEntryService (use cases nad WorkEntry) │
│ - IWorklogSubmissionService (pipeline odesílání worklogů) │
│ - IUnitOfWork / IUnitOfWorkFactory (transakce) │
│ - ISecureStorage (abstrakce nad OS credential storem) │
│ - IPluginManager (registr a lifecycle pluginů) │
│ - Result<T>, DTO, validátory, mapery │
└─────────────────────────────┬───────────────────────────────────────┘
│
┌─────────────────────────────▼───────────────────────────────────────┐
│ Domain │
│ - WorkEntry (entity) │
│ - IWorkEntryRepository (interface) │
│ - Business pravidla (validace časů, kategorizace, překryvy) │
│ - Žádné závislosti navenek │
└─────────────────────────────┬───────────────────────────────────────┘
│ implementováno v
┌─────────────────────────────▼───────────────────────────────────────┐
│ Infrastructure │
│ - WorkTrackerDbContext (EF Core + SQLite) │
│ - WorkEntryRepository, UnitOfWork, UnitOfWorkFactory │
│ - CredentialStoreSecureStorage (GitCredentialManager) │
│ - PluginManager, PluginLoader, PluginLoadContext (ALC) │
│ - MsalTokenProvider, MsalTokenProviderFactory │
│ - DependencyInjection.AddInfrastructure() │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Plugin.Abstractions (samostatná knihovna) │
│ - IPlugin, ITestablePlugin │
│ - IWorklogUploadPlugin, IWorkSuggestionPlugin, IStatusIndicator… │
│ - PluginBase, WorklogUploadPluginBase, … │
│ - PluginMetadata, PluginResult<T>, PluginConfigurationField │
│ - ITokenProvider, ITokenProviderFactory │
└─────────────────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
┌─────────┴──────┐ ┌───────┴────────┐ ┌─────┴──────┐ ┌───────┴────────┐
│ Plugin │ │ Plugin │ │ Plugin │ │ Plugin │
│ .Atlassian │ │ .GoranG3 │ │ .Luxafor │ │ .Office365Cal. │
└────────────────┘ └────────────────┘ └────────────┘ └────────────────┘
Čistý business model bez jakýchkoli externích závislostí. Obsahuje:
WorkEntry— agregát (ticket, start/end, description, flags).IWorkEntryRepository— interface pro persistenci, implementace je v Infrastructure.- Business pravidla — validace (start < end, nepřekrývající se intervaly), pomocné metody entity.
Domain layer nezná EF Core, plugins, DI, UI, nic. Je možné ji izolovaně unit‑testovat bez mockování.
Orchestruje use cases. Závislá pouze na Domain a Plugin.Abstractions. Klíčové typy:
| Typ | Účel |
|---|---|
IWorkEntryService / WorkEntryService |
Start/stop/edit/delete záznamů, detekce a řešení překryvů |
IWorklogSubmissionService / PluginBasedWorklogSubmissionService |
Pipeline odesílání worklogů přes pluginy |
IUnitOfWork / IUnitOfWorkFactory |
Transakční obal pro multi‑step zápisy |
IWorklogValidator, IDateRangeService |
Pomocné služby pro validaci a výpočet rozsahů |
ISecureStorage |
Abstrakce nad OS credential storem (implementace v Infrastructure) |
IPluginManager |
Registr a lifecycle pluginů (implementace v Infrastructure) |
Result<T> |
Výsledkový typ pro use cases (místo výjimek pro business chyby) |
DTO (WorklogDto, WorklogSubmissionDto, ProviderInfo, SubmissionResult) |
Přenosové typy mezi vrstvami |
WorkTrackerPaths |
Centralizované cesty (%LocalAppData%/WorkTracker, keys/, logs/, …) |
AppInfo |
Čtení verze z AssemblyInformationalVersionAttribute |
Registrace služeb: DependencyInjection.AddApplication(IServiceCollection). Volá se automaticky z AddInfrastructure, takže prezentační vrstva volá jen AddInfrastructure.
Implementace externích závislostí:
- EF Core + SQLite —
WorkTrackerDbContext,WorkEntryRepository,UnitOfWork,UnitOfWorkFactory. Cesta k databázi se řídíWorkTrackerPaths.DefaultDatabasePaths možností přepsání přesDatabase:Pathv configu. DbContextFactory— všechny repository používajíIDbContextFactory<WorkTrackerDbContext>, ne scopedDbContext. Důvod: plugin manager a dlouho běžící služby (GUI) nemají HTTP request scope, takže scoped DbContext by vedl k leakům a concurrent‑use chybám.CredentialStoreSecureStorage— implementaceISecureStoragenadGitCredentialManager(multiplatformní: Windows Credential Manager, macOS Keychain, Linux libsecret).PluginManager,PluginLoader,PluginLoadContext— načítání pluginů zAssemblyLoadContext(isolated, collectible), DI scope per plugin.MsalTokenProviderFactory,MsalTokenProvider— MSAL Public Client s cross‑platform token cache (Microsoft.Identity.Client.Extensions.Msal) a device code flow pro interaktivní autentizaci.DependencyInjection.AddInfrastructure(IConfiguration)— jediný entry point pro registraci Application+Infrastructure služeb. VoláAddApplication()sám.
Infrastructure závisí na Application a Domain, ne naopak.
Platformně neutrální UI logika:
- ViewModely (
MainWindowViewModel,SettingsViewModel,SuggestionsViewModel, …) —ObservableObjectzCommunityToolkit.Mvvm,[RelayCommand],[ObservableProperty]. ViewModely jsou úmyslně sdílené, ne per‑platform (oproti pohledům/XAML, které jsou platformně specifické). - Orchestrátory — třídy, které koordinují více služeb a skrývají workflow před ViewModely (např.
WorklogSubmissionOrchestratorsestaví náhled, otevře dialog, provede submit, zpracuje chyby). - Služby:
ISettingsService(serializaceApplicationSettingsdosettings.json),ILocalizationService(resx loader sINotifyPropertyChanged),IPomodoroService.
UI.Shared závisí na Application, ne přímo na Infrastructure.
Tři nezávislé projekty:
WorkTracker.CLI—Host.CreateApplicationBuilder, Serilog, Spectre.Console. Command dispatch je ručně vProgram.cs(switch statement), implementace příkazů vCommandHandler.WorkTracker.WPF— Windows‑only, Material Design.App.xaml.csstavíIHost, registruje ViewModely a views.WorkTracker.Avalonia— cross‑platform.App.axaml.csstaví DI v background threadu po zobrazení splash okna, aby start byl vizuálně rychlý.
Všechny tři prezentační projekty volají AddInfrastructure(configuration) a pak si přidávají své vlastní UI služby.
Závislosti jdou dovnitř, nikdy ven:
Presentation ──> UI.Shared ──> Application ──> Domain
│
▼
Plugin.Abstractions
▲
│
(implementováno pluginy)
Infrastructure ──> Application ──> Domain
Infrastructure ──> Plugin.Abstractions
Presentation ──> Infrastructure (jen pro AddInfrastructure)
Praktické důsledky:
- Application nezná EF Core.
WorkEntryServicepoužíváIWorkEntryRepository, neDbContext. - Presentation nezná pluginy přímo. Komunikuje přes
IWorklogSubmissionService,IPluginManager, což jsou Application interfaces. - Plugin.Abstractions je samostatný nuget‑like projekt, aby externí pluginy nemusely referencovat celé Application.
Projekt záměrně udržuje tři paralelní frontendy. Důvody:
- CLI — rychlé skriptování, CI integrace, headless servery, automatizace.
- WPF — nejlepší integrace s Windows (tray, taskbar, notifikace přes
NotifyIcon, jump lists). - Avalonia — cross‑platform, stejná funkčnost jako WPF, do budoucna preferovaný frontend.
Root ViewModely (například MainViewModel, SettingsViewModel) jsou v každém frontendu vlastní — WPF a Avalonia je nesdílejí. To je záměr. Drobné rozdíly v threadingu, messaging a styling bindings způsobí, že pokus o sdílený base class pro celé obrazovky končí kompromisy na obou stranách.
Sub‑ViewModely, které nemají framework‑specifickou vazbu, ale jsou naopak znovupoužitelné (například SuggestionsViewModel, pomodoro komponenty, plugin konfigurační VM), žijí v WorkTracker.UI.Shared.ViewModels a jsou skládané do root ViewModelů v obou frontendech. Vedle nich jsou v UI.Shared bezstavové služby, orchestrátory a settings.
ViewModely volají orchestrátory nebo přímo Application služby. Orchestrátor je užitečný, když akce:
- má dialog (submit worklogu ukazuje náhled, pak modal s výběrem pluginu),
- kombinuje víc služeb (načti entries → validuj → otevři dialog → submit → zpracuj chyby),
- má retry/recovery logiku.
Bez orchestrátoru by toto všechno skončilo ve ViewModelu a duplikovalo se mezi WPF a Avalonia.
Detaily pro autory pluginů jsou v plugin-development.md. Tady jen architektonické shrnutí.
Samostatná knihovna (WorkTracker.Plugin.Abstractions.dll) obsahující pouze veřejné API:
IPlugin,ITestablePluginIWorklogUploadPlugin,IWorkSuggestionPlugin,IStatusIndicatorPlugin- Abstraktní base classes
PluginBase,WorklogUploadPluginBase,WorkSuggestionPluginBase,StatusIndicatorPluginBase - Datové typy:
PluginMetadata,PluginConfigurationField,PluginResult<T>,PluginErrorCategory,PluginValidationResult - Přenosové typy:
PluginWorklogEntry,WorklogSubmissionResult,WorklogSubmissionError,WorkSuggestion,StatusIndicatorState - Enum kapacit:
WorklogSubmissionMode([Flags]—Timed,Aggregated) pro inzerci režimů, které plugin přijímá - Autentizace:
ITokenProvider,ITokenProviderFactory
Každý plugin se načítá do vlastního PluginLoadContext (dědí AssemblyLoadContext(isCollectible: true)):
- Sdílené assembly (
WorkTracker.Plugin.Abstractions,Microsoft.Extensions.*, runtime) se vrací do Default contextu přes overrideLoad→null, takže se nereferují duplicitně. - Ostatní závislosti plugin nese vedle sebe ve své výstupní složce a
AssemblyDependencyResolverje řeší lokálně. - Kontext je collectible → lze ho unloadnout při
PluginManager.UnloadPluginAsync.
Důsledek: plugin může používat jinou verzi závislosti než hlavní aplikace (pokud to závislost dovolí), a při chybě pluginu lze plugin shodit bez restartu aplikace.
PluginManager staví vlastní ServiceCollection pro pluginy, který obsahuje:
ILoggerFactory/ILogger<T>(shared)IHttpClientFactoryITokenProviderFactory(MSAL)ISecureStorage
Plugin se pak instanciuje přes ActivatorUtilities.CreateInstance z tohoto scoped providera, takže konstruktor může brát libovolnou kombinaci těchto služeb. Žádné parameterless konstruktory.
PluginLoader.DiscoverPluginFiles skenuje adresáře a hledá soubory vyhovující WorkTracker.Plugin.*.dll (kromě WorkTracker.Plugin.Abstractions.dll, která je vyloučená v DependencyInjection.InitializePluginsAsync). Výchozí adresář: {AppContext.BaseDirectory}/plugins.
PluginManager interně podporuje i registraci zabudovaného pluginu přes LoadEmbeddedPlugin<T>(), ale aktuální bootstrap v InitializePluginsAsync tuto cestu nepoužívá — všechny pluginy jsou načítané dynamickým discovery z adresáře.
Discover → Load assembly do PluginLoadContext
→ Instantiate(DI)
→ Register in _loadedPlugins
→ InitializeAsync(config, ct)
└ ValidateConfigurationAsync
└ OnInitializeAsync (hook)
→ Ready to use
Shutdown/Unload:
→ ShutdownAsync
└ OnShutdownAsync (hook)
└ DisposeAsync
→ Remove from _loadedPlugins
→ PluginLoadContext.Unload()
PluginBase implementuje šablonové metody tak, že autor pluginu override‑uje jen hooks (OnInitializeAsync, OnShutdownAsync, OnValidateConfigurationAsync, OnDisposeAsync).
Use cases nevrhají výjimky pro business chyby. Vrací Result<T>:
public async Task<Result<WorkEntry>> StartWorkAsync(
string? ticketId, DateTime? startTime, string? description,
DateTime? endTime, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(ticketId) && string.IsNullOrWhiteSpace(description))
{
return Result<WorkEntry>.Failure("Ticket ID nebo popis je povinný.");
}
// …
return Result<WorkEntry>.Success(entry);
}Volající (ViewModel, CLI handler) prostě zkontroluje IsSuccess:
var result = await _workEntryService.StartWorkAsync(…);
if (result.IsFailure)
{
_notificationService.ShowError(result.Error);
return;
}Výjimky jsou vyhrazeny pro:
- programátorské chyby (
ArgumentNullException, invarianty) - skutečné infrastructure selhání (I/O, DbException) — ty propagují do handlerů a logují se přes Serilog
IUnitOfWork obaluje sérii zápisů do jedné EF Core transakce (IDbContextTransaction). Používá se pro multi‑step operace, kde částečný úspěch by nechal databázi v nekonzistentním stavu:
- Auto‑stop předchozího + start nového (CLI
starts už běžícím záznamem) - Edit se vzniklým překryvem, kdy aplikace zároveň ořízne kolidující záznam
await using var uow = await _uowFactory.CreateAsync(ct);
await uow.WorkEntries.AddAsync(newEntry, ct);
await uow.WorkEntries.UpdateAsync(trimmedOldEntry, ct);
await uow.SaveChangesAsync(ct); // commitPokud SaveChangesAsync není zavoláno a uow se dispose‑ne, transakce se rollbackne. WorkEntryRepository má dva režimy:
- Factory režim (standalone) — každá operace si sama otevře
DbContextz factory a rovnou commituje. - Shared‑context režim (v rámci UoW) — operace přispívají do
DbContextdrženého UoW, SaveChanges je odložen na UoW.
WorkEntryService pro jednoduché operace používá transient repository přímo; pro vícekrokové zavolá UoW.
WorkTracker nepoužívá klasický scoped DbContext (jako v ASP.NET Core request scope), protože aplikace není request‑driven. Místo toho:
AddDbContextFactory<WorkTrackerDbContext>()v DI- Konzumenti injektují
IDbContextFactory<WorkTrackerDbContext>a volajíCreateDbContext()per operace - Krátké using bloky → žádné leaky
ViewModely v UI.Shared jsou tenké — delegují akce na orchestrátory/services. Orchestrátor je stateless třída v Application layer nebo UI.Shared, která zapouzdřuje workflow s dialogy, validací a error recovery.
PluginBase je šablonová metoda:
public virtual async Task<bool> InitializeAsync(
IDictionary<string, string>? config, CancellationToken ct)
{
Configuration = config ?? new Dictionary<string, string>();
var validation = await ValidateConfigurationAsync(Configuration, ct);
if (!validation.IsValid) { /* log, return false */ }
var ok = await OnInitializeAsync(Configuration, ct); // hook
IsInitialized = ok;
return ok;
}Autor pluginu override‑uje jen OnInitializeAsync, nemusí řešit validaci ani stav.
Jako konkrétní ilustraci vezmeme tok „Send today“ z Avalonia GUI do Tempa:
- UI — Uživatel v
MainWindowklikne na Send today. View zavoláSubmitTodayCommandveMainWindowViewModel. - ViewModel — Deleguje na
WorklogSubmissionOrchestrator.SubmitDayAsync(DateTime.Today). - Orchestrátor — Zavolá
IWorklogSubmissionService.PreviewDailyWorklogAsync(today)a otevřeSubmitWorklogDialogs výsledkem. - PluginBasedWorklogSubmissionService.PreviewDailyWorklogAsync:
- Načte
IEnumerable<WorkEntry>přesIWorkEntryService.GetWorkEntriesByDateAsync(today). - Mapuje přes
WorklogMappernaList<WorklogDto>. - Validuje každý přes
IWorklogValidator→ odfiltruje neplatné. - Vrátí
WorklogSubmissionDto(platné worklogy + důvody odfiltrovaných).
- Načte
- Dialog — Uživatel vidí náhled, vybere submission mode (Timed / Aggregated — persistováno v
ApplicationSettings.LastSubmissionMode), vybere plugin (dropdown je filtrovaný přesProviderInfo.SupportedModes.HasFlag(mode), takže providery nepodporující zvolený mode tam nejsou) a potvrdí. V Aggregated módu orchestrátor před zobrazením preview seskupí DTO podle(TicketId, Description)per den a součtem nastavíDurationMinutes;StartTimeskupiny = nejstarší start. - Orchestrátor — Zavolá
IWorklogSubmissionService.SubmitCustomWorklogsAsync(worklogs, providerId, mode, ct)(z dialogu;SubmitDailyWorklogAsyncje dnes použit jen z CLI a drží Timed default). - PluginBasedWorklogSubmissionService.SubmitCustomWorklogsAsync:
- Validuje, že
modeje single value (Timed nebo Aggregated) — jinakFailure. ResolvePlugin(providerId)→ vrátíIWorklogUploadPlugin.- Ověří, že plugin daný mode podporuje (
plugin.SupportedModes.HasFlag(mode)). - Pro každý validní
WorklogDtovytvoříPluginWorklogEntry. - Zavolá
plugin.UploadWorklogsAsync(entries, mode, ct).
- Validuje, že
- Plugin (Tempo) —
TempoWorklogPluginprochází záznamy v cyklu (podporuje oba módy:SupportedModes = Timed | Aggregated).- Pro každý: přeloží issue key → issue ID (s 1h cache), zavolá Tempo REST API
POST /worklogs. - V Timed módu payload obsahuje
startDate + startTime + timeSpentSeconds; v Aggregated módu pluginstartTimez payloadu zcela vynechá (Tempo ho má jako volitelné pole). - Retry logika (max 2 pokusy, backoff) na 408/429/500–504.
- Vrátí
PluginResult<WorklogSubmissionResult>s počty a případnými chybami.
- Zpět do služby — mapuje
PluginResult<WorklogSubmissionResult>→Result<SubmissionResult>(DTO layer). - Orchestrátor — Pokud success, zobrazí toast a refresh seznam. Pokud partial/failure, dialog zobrazí detaily chyb s tlačítkem Retry failed.
Celý tok demonstruje:
- Vrstvy: UI → Orchestrator → Application service → Plugin (přes abstraction).
- Separation: Application layer nezná HTTP nebo Tempo API — jen
IWorklogUploadPlugin. - Result pattern: na každé hranici se chyby propagují jako data, ne jako výjimky.
- Plugin isolation: Tempo implementace sedí v separátní DLL v
plugins/, může být updatována nezávisle.