Tical SDK

Every context in Tical — Personal, Jira, ClickUp, Pomodoro — is a plugin built against the same two assemblies your plugin references. There is no privileged internal API: the bundled integrations are worked examples, not special cases.

Tical.SDK

Contexts and UI. Avalonia-based: view slots, full-screen pages, the New/Edit entry contribution, settings sections, validation, and the SyncedContextBase<TProject, TTask> tracker pattern.

Tical.SDK.Core

No UI. The data model (TimeEntry, WorkSession, Tag), the host services a context is handed, the durable sync store, and the exporter API.

Overview#

A context is one way of working, shown in the switcher at the top of the main window. Its required core is small:

  • which entries are visible — Filter, defaulting to the ones this context created,
  • what data new entries get stamped with — ApplyToEntry,
  • identity and chrome — Id, Name, IconPathData, ShowTags, ShowProject.

Everything else is optional and opted into through the capability interfaces in Tical.Contexts. The switcher, the entry list and the timer are drawn for you.

Namespaces: contexts and UI live in Tical.Contexts, the plugin entry point in Tical.Plugins, models in Tical.Models, the sync store in Tical.Services, and exporters in Tical.Exporting.

Which base class do I want?#

AppContextBase

The general case. Observable state, no-op lifecycle hooks, and a plain project text box in the project slots. It implements every capability interface, so a context that derives from it gets them all and overrides only what it cares about.

SyncedContextBase<,>

Time tracked against an external service through a project → task picker and pushed back as worklogs. Derives from AppContextBase and adds the pickers, refresh/sync commands, pending-entry status, auto-sync and duplicate prevention.

Implementing IAppContext directly is supported but rarely worth it — you would be re-writing the observable plumbing and the default views by hand.

Installation#

A .NET class library targeting net10.0 that references both SDK assemblies. Tical.SDK pulls in Tical.SDK.Core transitively; referencing both directly keeps it explicit.

MyTracker.csproj
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <Nullable>enable</Nullable>
    </PropertyGroup>
    <ItemGroup>
        <Reference Include="Tical.SDK" Private="false">
            <HintPath>C:\Path\To\Tical\Tical.SDK.dll</HintPath>
        </Reference>
        <Reference Include="Tical.SDK.Core" Private="false">
            <HintPath>C:\Path\To\Tical\Tical.SDK.Core.dll</HintPath>
        </Reference>
    </ItemGroup>
</Project>

Building inside the Tical repository? Use project references instead — the same Private="false" applies:

In-repo variant
<ProjectReference Include="..\..\Tical.SDK\Tical.SDK.csproj" Private="false" />
<ProjectReference Include="..\..\Tical.SDK.Core\Tical.SDK.Core.csproj" Private="false" />
Private="false" is not optional. Tical.SDK.dll, Tical.SDK.Core.dll and Avalonia must come from the host application, not from your plugin folder, so the types stay identical across the boundary. Ship your own dependencies and nothing else.

Out-of-tree plugins keep package versions inline and must match the host's. In-repo plugins omit Version on their PackageReferences — versions come from Directory.Packages.props at the repository root.

Deployment#

Copy your plugin's build output into its own subfolder of either:

  • %LocalAppData%\Tical\Plugins\<YourPlugin>\
  • <Tical install dir>\Plugins\<YourPlugin>\

Every DLL directly inside that folder is scanned at startup for public, non-abstract ITicalPlugin implementations, each instantiated through its parameterless constructor.

Each plugin loads in an isolated AssemblyLoadContext that tries the host first for every assembly resolution. A plugin that fails to load is logged and skipped — it never takes the app down with it.

Quickstart#

Two classes put you in the context switcher.

MyTrackerPlugin.cs
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Controls;
using Tical.Contexts;
using Tical.Models;
using Tical.Plugins;

public class MyTrackerPlugin : ITicalPlugin
{
    public string Name => "MyTracker";

    public IEnumerable<IAppContext> CreateContexts(IContextHost host)
    {
        yield return new MyTrackerContext(host);
    }
}

public class MyTrackerContext : AppContextBase
{
    private readonly IContextHost _host;

    public MyTrackerContext(IContextHost host) => _host = host;

    // Stable — it is persisted to remember the active context. Never change it.
    public override string Id   => "MyTracker";
    public override string Name => "My Tracker";

    // Contribute UI. Slots: MainBar, ProjectInput, CompactInline, CompactExtra.
    public override Control? BuildView(ContextViewSlot slot) => slot switch
    {
        ContextViewSlot.MainBar => BuildBar(),
        _ => base.BuildView(slot)
    };

    private Control BuildBar()
    {
        var status = ContextControls.StatusText(nameof(CurrentProject));
        status.DataContext = this;   // slot views bind to the context itself
        return status;
    }

    // A finished timer run — push it wherever it belongs.
    public override Task OnEntryCompleted(TimeEntry entry)
    {
        _host.Notifications?.Show(Name, $"Tracked {entry.FormattedDuration}");
        return Task.CompletedTask;
    }
}

IContextHost is your only way back into the app — settings storage, entry reads, the timer, notifications. Hold on to it; see Host services. Note the ?. on Notifications: it is a defaulted member that can be null.

Plugin entry point#

ITicalPlugin#

public interface ITicalPlugin
Tical.SDKTical.Plugins

Entry point of a Tical plugin assembly. Tical scans the Plugins folder at startup, instantiates every public non-abstract implementation of this interface via its parameterless constructor and registers the contexts it creates.

Members

Name string required

Display name of the plugin, used in logs and diagnostics.

CreateContexts(IContextHost host) IEnumerable<IAppContext> required

Creates the contexts this plugin contributes. Called once at startup.

IAppContext#

public interface IAppContext : INotifyPropertyChanged
Tical.SDKTical.Contexts

A context defines how Tical behaves for one way of working: which entries are visible and what data new entries get. Implement it — usually via AppContextBase — to integrate an external service such as Jira.

Properties#

Id string required

Stable unique identifier, persisted to remember the active context. Never change it between versions.

Name string required

Display name shown in the context switcher.

IconPathData string? required

Optional SVG path data rendered as an icon next to the name in the context switcher. Return null for no icon.

IsActive bool { get; set; } required

Set by the host while this context is the active one. Do not set it yourself.

CurrentProject string { get; set; } required

The project value applied to new entries. The default ContextViewSlot.ProjectInput view edits this; custom project pickers should write their selection here.

ShowTags bool required

Whether the host shows the tag editor for this context.

ShowProject bool required

Whether the host shows the project input row for this context.

ShowManualAdd bool default impl

Whether the host shows the "add manual entry" button for this context. Defaults to true.

This is a default interface member, added after ShowTags and ShowProject, which predate the concern. Existing third-party IAppContext implementations keep compiling without picking it up explicitly.

Methods#

Filter(TimeEntry entry) bool required

Returns true when the entry belongs to this context and should be listed.

ApplyToEntry(TimeEntry entry) void required

Stamps context-specific data (workspace, project, issue key, …) onto a new entry before it is saved.

AppContextBase#

public abstract partial class AppContextBase : ObservableObject, IAppContext, IContextViewProvider, IEntryDecorator, IContextSettingsProvider, IEntryEditorProvider, IContextLifecycle
Tical.SDKTical.Contexts

The recommended base class. It provides observable state, no-op lifecycle hooks and default views that subclasses override. Because it implements every capability interface, deriving from it gives you all of them for free.

You must implement#

Id string abstract

Stable unique identifier. Also the default folder name of the plugin's sync database.

Name string abstract

Display name shown in the context switcher.

You may override#

IconPathData string? virtual

Defaults to null.

ShowTags bool virtual

Defaults to true.

ShowProject bool virtual

Defaults to true. SyncedContextBase overrides it to false — its pickers replace the host's project row.

ShowManualAdd bool virtual

Defaults to true.

CompactModeHeight double virtual

Window height in pixels while compact mode is active for this context. Defaults to 100. Raise it when you fill CompactExtra.

Filter(TimeEntry entry) bool virtual

Lists the entries this context created — OwnsEntry(entry). Override to narrow it (the Personal context adds its active workspace) or to widen it (the tracker contexts show everything, since work tracked elsewhere can still be pushed to Jira or ClickUp).

ApplyToEntry(TimeEntry entry) void virtual

Writes CurrentProject into entry.Project, falling back to TicalConstants.NoProject when it is blank.

BuildView(ContextViewSlot slot) Control? virtual

Returns a two-way bound project text box for ProjectInput and CompactInline while ShowProject is true, and null otherwise. See View slots.

GetEntryBadge(TimeEntry entry) EntryBadge? virtual

Defaults to null — no badge.

GetEntryActions(TimeEntry entry) IReadOnlyList<EntryAction> virtual

Defaults to an empty array.

CreateSettingsSection() ContextSettingsSection? virtual

Defaults to null — no settings section.

CreateEntryEditor(TimeEntry? original) EntryEditor? virtual

Returns the default plain-project editor, which mirrors the default ProjectInput view. It is created even when the project row is hidden, so the entry's project text survives an edit.

OnAppStarted() · OnActivated() · OnDeactivated() · OnEntryCompleted(TimeEntry) · OnEntryUpdated(TimeEntry) · OnEntryDeleted(Guid) · OnSettingsChanged() Task virtual

All seven default to Task.CompletedTask. See Lifecycle.

You get for free#

IsActive bool { get; set; } observable

Maintained by the host. Generated from an [ObservableProperty] field, so it raises change notifications for your bindings.

CurrentProject string { get; set; } observable

The project stamped onto new entries. Bindable.

OwnsEntry(TimeEntry entry) bool protected

True when the entry was created under this context. The host stamps TimeEntry.ContextId once, at creation, from the active context — a context never writes it itself, so it cannot forget to claim its entries nor claim another's.

ClearProject() · ClearProjectCommand void · IRelayCommand command

Clears CurrentProject. The [RelayCommand] generator adds ClearProjectCommand for binding.

Capability interfaces#

Optional behaviour is opted into by implementing one of these. All of them are already implemented by AppContextBase, so a context deriving from it has every capability unless it overrides otherwise.

InterfaceAdds
IContextViewProvider BuildView — extra UI in the main window, one control per slot — and CompactModeHeight.
IEntryEditorProvider CreateEntryEditor — how the New/Edit entry window behaves: slot views, validation, save stamping.
IEntryDecorator GetEntryBadge (small status glyph) and GetEntryActions (extra row context-menu items).
IContextSettingsProvider CreateSettingsSection — its own section in the settings window.
IContextLifecycle OnAppStarted, OnActivated, OnDeactivated, OnEntryCompleted, OnEntryUpdated, OnEntryDeleted, OnSettingsChanged.
ISessionAnnotator AnnotateSession — stamp a ManualLabel onto the just-finished work session before it is saved.

ISessionAnnotator#

public interface ISessionAnnotator

For a context that stamps a label onto a just-finished work session (for example "Pomodoro 3") uniformly, whether the timer was stopped manually or programmatically. Called after the session is created but before it is saved.

AnnotateSession(TimeEntry entry, WorkSession session) void required

Annotates the session and/or its entry before it is persisted.

ISessionAnnotator is the one capability AppContextBase does not implement. Implement it on your context directly when you need it.

ContextCapabilityExtensions#

public static class ContextCapabilityExtensions

Reaches a context's optional capability interfaces with safe defaults, so host call-sites stay one-liners regardless of which capabilities a context implements. Lifecycle forwarders are suffixed Async to avoid colliding with the IContextLifecycle members of the same name.

This is host-side plumbing. You never call it from inside a plugin; it only matters if you are reading the host source. It is documented here because it is public API, not because you need it.
BuildView(this IAppContext, ContextViewSlot) Control? static

Null when the context does not implement IContextViewProvider.

GetCompactModeHeight(this IAppContext) double static

100 when the context does not implement IContextViewProvider.

GetEntryBadge(this IAppContext, TimeEntry) EntryBadge? static

Null when the context does not implement IEntryDecorator.

GetEntryActions(this IAppContext, TimeEntry) IReadOnlyList<EntryAction> static

Empty when the context does not implement IEntryDecorator.

CreateSettingsSection(this IAppContext) ContextSettingsSection? static

Null when the context does not implement IContextSettingsProvider.

CreateEntryEditor(this IAppContext, TimeEntry? original) EntryEditor? static

Null when the context does not implement IEntryEditorProvider.

OnAppStartedAsync · OnActivatedAsync · OnDeactivatedAsync · OnEntryCompletedAsync · OnEntryUpdatedAsync · OnEntryDeletedAsync · OnSettingsChangedAsync Task static

All extend IAppContext and forward to the matching IContextLifecycle member; a no-op returning Task.CompletedTask when the context does not implement it. OnEntryCompletedAsync and OnEntryUpdatedAsync take a TimeEntry; OnEntryDeletedAsync takes a Guid.

AnnotateSession(this IAppContext, TimeEntry, WorkSession) void static

A no-op when the context does not implement ISessionAnnotator.

Navigation(this IContextHost host) IContextNavigation? static

The host's page navigation, or null on hosts that predate this surface. Note this one extends IContextHost, not IAppContext — it reaches a host's optional capability. This is the call you do make from a plugin; see Full-screen pages.

Lifecycle#

public interface IContextLifecycle
Tical.SDKTical.Contexts
OnAppStarted() Task

Called once at application startup for every registered context.

OnActivated() Task

Called when this context becomes the active one.

OnDeactivated() Task

Called when another context takes over.

OnEntryCompleted(TimeEntry entry) Task

Called after a timer run has been stopped and the entry saved. Typical use: auto-sync to an external service. Raised for the active context.

OnEntryUpdated(TimeEntry entry) Task

Called after an entry was created or edited through the New/Edit window and saved. Unlike OnEntryCompleted this is raised for every registered context, not only the active one, so bookkeeping — sync status, entry links — can stay current.

OnEntryDeleted(Guid entryId) Task

Called after an entry was deleted, or merged away into another entry (its id no longer exists). Raised for every registered context. Typical use: dropping sync records and entry links kept for the entry.

OnSettingsChanged() Task

Called when the user leaves the settings window, so the context can pick up changed configuration.

Entry ownership#

Every entry remembers the context that created it. The host stamps TimeEntry.ContextId once, at creation, from the active context — a context never writes it itself, so it cannot forget to claim its entries nor claim another's.

AppContextBase.Filter therefore defaults to OwnsEntry(entry), which is simply entry.ContextId == Id. Rows written before the column existed are backfilled to TicalConstants.DefaultContextId ("Default"), the built-in Personal context.

Tracker contexts deliberately widen this. SyncedContextBase.Filter lists its own entries plus any entry stamped with one of its external keys, whoever created it — so work tracked in Personal and later linked to a Jira issue still shows up where you can push it.

View slots#

public interface IContextViewProvider public enum ContextViewSlot { MainBar, ProjectInput, CompactInline, CompactExtra }
Tical.SDKTical.Contexts

You contribute controls into named regions of a window you do not own, so every integration lines up. Return null for a slot and it collapses — which is exactly how the Pomodoro context ends up with no project row at all.

  • MainBarPanel directly below the context switcher, normal mode. Typical use: pickers and actions — workspace or issue selection.
  • ProjectInputThe project input row below the description box, normal mode. A default text box is provided by AppContextBase.
  • CompactInlineThe small inline field next to the description box in compact mode. A default text box is provided by AppContextBase.
  • CompactExtraAn optional extra row spanning the compact window. Increase CompactModeHeight when you use it.
  • ContextPageNot a slot: a full-screen page that slides over the main view and awaits a result. See Full-screen pages.

IContextViewProvider#

BuildView(ContextViewSlot slot) Control? required

Returns the control for a host UI slot, or null when the slot should stay empty. Called whenever the host needs a view, so it must return a new control instance on each call. Bind the control to the context itself for state.

CompactModeHeight double required

Window height in pixels used when compact mode is active for this context.

A control can only ever have one visual parent, and the host may build a slot more than once. Caching and returning the same instance makes it go missing from all but one window.

Your views render inside the host's visual tree, so the theme brushes and the action / minor / danger button classes apply automatically. Compose from ContextControls and your panel is indistinguishable from a built-in one.

Full-screen pages#

Need the whole window? Push a ContextPage and await its result. The host renders the back button, centered title and an optional right-aligned action button; you supply only the body.

Opening a page
var nav = Host.Navigation();
if (nav is null) return;  // host predates page navigation

var page = new ContextPage
{
    Title = "Pick an issue",
    CreateContent = p => new IssueList(p, _issues),
    ActionLabel = "Refresh"
};

var result = await nav.ShowPageAsync(page);
if (result is Issue picked) SelectedTask = picked;

ContextPage#

public sealed class ContextPage
Title string { get; init; } required

Title shown centered in the page header.

CreateContent Func<ContextPage, Control> { get; init; } required

Called once when the page opens. It receives this page — so the content can call Close on it, for example from a clicked list row — and returns the page body.

ActionLabel string? { get; init; } optional

Optional right-aligned header action, e.g. "Save" or "Refresh".

ActionCommand ICommand? { get; set; } optional

Command invoked by the header action button; ignored when ActionLabel is null or blank. Read when the page is shown — any time up to and including inside CreateContent — so it may be assigned after construction, for example to a command that captures this page and calls Close on it.

Closed CancellationToken read-only

Cancelled when the page closes by any route — Close, the user going back, the host navigating elsewhere, or a second page superseding this one. Pass it into the page's async work (fetches, image loads) so the request is dropped the moment the page goes away, and no cleanup code of your own is needed.

Close(object? result = null) void

Closes this page and completes its ShowPageAsync task with the given result. A no-op once the page has closed, so a late call can never dismiss whatever page is open by then.

IContextNavigation#

public interface IContextNavigation
ShowPageAsync(ContextPage page) Task<object?> required

Slides the page in over the main view and completes when it closes — with the value passed to Close(), or null when the user goes back or the host navigates elsewhere. Single level only: there is no page stack, so opening a second page closes the first with a null result.

Reach it with host.Navigation() from ContextCapabilityExtensions, which returns null on hosts that predate this surface.

ContextPageNavigator#

public abstract class ContextPageNavigator : IContextNavigation

Host-side base for IContextNavigation: owns the single open page and its pending completion, and the reference-check that keeps a stale ContextPage.Close call from dismissing a page opened since. Hosts supply display and thread-marshalling by overriding the members below. Plugin authors do not implement this — the app does.

ShowPageAsync(ContextPage page) Task<object?>

The IContextNavigation implementation. Continuations run asynchronously, and a throw from ShowPage resolves the task as dismissed rather than faulting it.

ShowPage(ContextPage page) void abstract protected

Navigate to the page so it slides in over the main view. May throw — a plugin's CreateContent runs from here.

GoBack() void abstract protected

Return from the page to the main view.

Invoke(Action action) void virtual protected

Hook for thread-marshalling, e.g. onto the UI thread. Runs synchronously by default.

NotifyPageAbandoned() void protected

Call when the user left the page by another route than Close — the title-bar Settings button, say. Resolves the awaiting plugin with null instead of leaving it hanging.

New/Edit entry window#

public interface IEntryEditorProvider public abstract partial class EntryEditor : ObservableObject public enum EntryEditSlot { Project, Extra }
Tical.SDKTical.Contexts

One context's contribution to one New/Edit entry window: the views for the window's slots plus the code actions around saving — validation, stamping context data onto the entry, and defaults such as the fill-up increment.

This mirrors the main window's BuildView mechanism, but a fresh editor is created for every window, so selection state lives on the editor and never bleeds into the tracker UI. Bind slot views to the editor itself.

  • ProjectThe project area of the window — label plus input. A plain project text box is provided by AppContextBase.
  • ExtraAn optional extra section rendered below the built-in fields. Typical use: sync status of the edited entry.

IEntryEditorProvider#

CreateEntryEditor(TimeEntry? original) EntryEditor? required

Creates this context's contribution to a New/Edit entry window, or null to leave the window's built-in fields alone. Called once per window; original is the entry being edited, or null when a new entry is created.

EntryEditor#

protected EntryEditor(TimeEntry? original)

The constructor seeds CurrentProject from the edited entry, treating TicalConstants.NoProject as empty.

OriginalEntry TimeEntry? { get; }

The entry being edited, or null when the window creates a new entry.

CurrentProject string { get; set; } observable

The project text the window saves into TimeEntry.Project. Initialized from the edited entry; project slot views should write the user's choice here.

BuildView(EntryEditSlot slot) Control? virtual

Returns the control for an edit-window slot, or null when the slot should stay empty (the host collapses it). Return a new control instance on each call and bind it to the editor itself. Defaults to null.

OnOpened() Task virtual

Called once after the window opened. Load pickers or other async state here.

ValidationRules IEnumerable<ValidationRule> virtual protected

The editor's preconditions for saving, evaluated in order when the user presses Save. The declarative counterpart of Validate: override this for plain "field X must be filled" rules and let the default Validate evaluate them. Re-read on every attempt, so rules may close over picker state that changes while the window is open. Defaults to empty.

Validate() string? virtual

Runs when the user presses Save. Returns an error message that blocks saving and is shown in the window, or null when the current input is valid. Defaults to the first unmet ValidationRules rule; override directly when the check does not fit a rule set.

ApplyToEntry(TimeEntry entry) void virtual

Stamps context-specific data (workspace, issue marker, task link, …) onto the entry being saved, after the window applied the generic fields — description, project, date, duration, tags. Called for new and edited entries. Does nothing by default.

GetFillUpMinutes() int? virtual

The "Fill Up" rounding increment in minutes for the entry being edited, or null to use the application default. Lets a context provide per-project increments.

Badges & row actions#

public interface IEntryDecorator public sealed record EntryBadge(string Text, string? Tooltip = null); public sealed record EntryAction(string Label, Func<Task> Execute, string? IconPathData = null);
Tical.SDKTical.Contexts
GetEntryBadge(TimeEntry entry) EntryBadge? required

Returns a small status badge for an entry's row in the list — "✓" pushed, "↑" pending sync — or null for none. Must be fast and non-blocking: it runs for every visible entry whenever the list is rebuilt.

GetEntryActions(TimeEntry entry) IReadOnlyList<EntryAction> required

Returns extra context-menu actions for an entry's row — open in browser, push now — or an empty list for none. Like GetEntryBadge this runs per visible entry and must be fast; the actions themselves may be async.

EntryBadge#

Text string required

A short glyph or text — "✓", "↑".

Tooltip string? optional

Optional tooltip explaining the glyph. Defaults to null.

EntryAction#

Label string required

Menu item text — "Open in Jira", "Push now".

Execute Func<Task> required

What the item does. May be async.

IconPathData string? optional

Optional SVG path data in the same 24×24 format as IAppContext.IconPathData. Defaults to null.

Validation#

Rules are evaluated on attempt: the action stays enabled and the message appears when the user tries to perform it, rather than the control being silently disabled with no explanation.

ValidationRule#

public sealed record ValidationRule(Func<bool> IsSatisfied, string Message);
IsSatisfied Func<bool> required

Evaluated each time the action is attempted; true when the rule passes.

Message string required

Shown to the user when IsSatisfied returns false.

Validation#

public static class Validation
FirstError(IEnumerable<ValidationRule> rules) string? static

The first unmet rule's message, or null when every rule passes. Rules are evaluated in order and evaluation stops at the first failure, so list the most fundamental precondition first — the user is only told about one thing at a time.

FirstError(params ValidationRule[] rules) string? static

Params overload of the above.

ValidatedCommand#

public sealed class ValidatedCommand : ICommand

Wraps an action in a validation pre-check, for buttons the host builds and only lets a plugin supply a command for — ContextPage.ActionCommand above all.

ValidatedCommand(Func<string?> validate, Func<Task> execute, Action<string> onInvalid) constructor

A command guarded by an arbitrary validation function. validate returns the message to report, or null to let the action run; onInvalid is where a failure message is shown, typically the context's status line.

ForRules(Func<IEnumerable<ValidationRule>> rules, Func<Task> execute, Action<string> onInvalid) ValidatedCommand static

The usual way to build one. The rules are re-read on every attempt, so a rule may close over state that changes while the button is on screen. A named factory rather than a second constructor, because a lambda argument cannot be told apart from the Func<string?> the constructor takes.

CanExecute(object? parameter) bool

Always true — validation happens on Execute, not by disabling the control. A disabled button tells the user nothing about what is missing.

CanExecuteChanged event EventHandler?

Never raised: the command is always executable, so the button never disables.

Execute(object? parameter) void

Reports the first unmet rule and does nothing else, or runs the action when all rules pass. A throwing action is caught and logged rather than escaping as an unobserved task exception — actions are expected to report their own failures.

Settings section#

public interface IContextSettingsProvider public sealed class ContextSettingsSection
Tical.SDKTical.Contexts

The host renders the standard section chrome — card, title, description — and your context supplies only the inner content.

ContextSettingsSection#

Title string { get; init; } required

Section title, e.g. "Jira Integration".

Description string? { get; init; } optional

Optional one-line description rendered under the title.

RequiresPro bool { get; init; } optional

When true, the host disables the section for unlicensed users and shows a PRO badge.

CreateContent Func<Control> { get; init; } required

Factory for the section content. Called every time the settings window is built, so it must return a new control instance on each call.

ContextSettingsViewModelBase#

public abstract partial class ContextSettingsViewModelBase : ObservableObject

Base view model for a context's settings section: an auto-sync toggle plus the test-connection plumbing — busy flag, colored result. Subclasses add their own connection fields, persist in Save, and implement the actual connection test.

AutoSync bool { get; set; } observable

Whether completed entries are pushed automatically. Changing it calls SaveIfLoaded().

IsTesting bool { get; set; } observable

True while a connection test is running.

TestResult string? { get; set; } observable

The message from the last test, or null.

IsTestSuccess bool { get; set; } observable

Drives the green/red colour of the result line.

Loaded bool { get; set; } protected

Set to true at the end of the subclass constructor so initial property assignment does not save.

SaveIfLoaded() void protected

Call from the subclass's property-changed hooks to persist on change.

Save() void abstract protected

Persists the current values via the context settings store.

TestConnectionCoreAsync() Task<(bool Success, string Message)> abstract protected

Runs the connection test and returns success plus the message to show.

TestConnection() · TestConnectionCommand Task · IAsyncRelayCommand command

Wraps TestConnectionCoreAsync with the busy flag and turns a throw into "Error: …" on the result line. Bind TestConnectionCommand, or just drop in SettingsControls.TestConnectionArea(), which wires all of it.

Controls & theming#

Code-built building blocks styled to the host's conventions. Compose from these and your UI lines up with the built-in integrations without copying any XAML.

ContextControls#

public static class ContextControls
RefreshIconPath · SyncIconPath · CloseIconPath const string static

24×24 Material Design Icons path data: refresh arrows, upload arrow, close/clear X.

PickerPadding Thickness static readonly

new(12, 10) — the inner padding the host applies to its ComboBox style. Mirrored here so a hand-padded control lines up with a plain picker.

PickerComboBox(string placeholder, string itemsPath, string selectedItemPath, Func<object?, string?> displayText, double fontSize = 14) ComboBox static

A stretch combo box for picker rows, bound by property name to an items collection and a two-way selected item. Items are rendered through displayText.

SearchablePickerBox(string placeholder, string itemsPath, string selectedItemPath, Func<object?, string?> displayText, double fontSize = 14) AutoCompleteBox static

A type-to-search picker for long item lists, bound like PickerComboBox. Matching is a case-insensitive substring test against displayText; focusing the empty box drops the full list down, so it still works like a combo box.

IconButton(string iconPathData, string tooltip, string commandPath, string? busyPath = null) Button static

A small "minor" icon button bound to a command by name. When busyPath is given, the icon is swapped for an indeterminate progress spinner while that boolean property is true.

FieldLabel(string text) TextBlock static

The small uppercase section label used by the host's New/Edit entry window.

LabeledField(string label, Control control) StackPanel static

An edit-window field in the host's convention: a FieldLabel above the control. Compose EntryEditor.BuildView slots from these.

StatusText(string textPath, string? kindPath = null) TextBlock static

Wrapping 11px status line, bound by property name. Without kindPath it uses the tertiary text colour; with it, the colour follows the bound SyncStatusKind — green success, orange warning, red error.

SettingsControls#

public static class SettingsControls

Building blocks for settings sections, matching the layout conventions of the built-in ones: 15px section spacing, label-over-input fields with 11px hints, toggle rows, a stretch test-connection block.

Section(object viewModel, params Control[] children) StackPanel static

The section root: a vertical stack with the standard spacing, bound to the view model.

Field(string label, Control input, string? hint = null, Control? extra = null) Control static

A labeled input with an optional 11px hint underneath. extra — a LinkHint, say — is rendered below the hint, inside the field group.

TextField(string bindingPath, string placeholder, bool isSecret = false) TextBox static

A text box bound two-way by property name; secrets are masked and get the theme's reveal-password eye toggle.

LinkHint(string text, string url, IContextHost host) Control static

An 11px accent-coloured link that opens url in the default browser via IContextHost.OpenUrl. Place it as a field's extra ("Create an API token ↗") or as its own section row.

NumericField(string bindingPath, int minimum, int maximum) NumericUpDown static

An int-bound NumericUpDown, whole numbers only, clamped to minimum/maximum.

Dropdown(string itemsPath, string selectedPath, string placeholder) ComboBox static

A combo box with its items and selection bound two-way by property name.

ToggleRow(string title, string description, string bindingPath) Control static

Title and explanation on the left, a ToggleSwitch on the right.

TestConnectionArea() Control static

The standard test-connection block for a ContextSettingsViewModelBase: a stretch action button, a thin progress bar while testing, and the result line coloured green/red by success. It binds to that base class's members by name, so the section's DataContext must be one.

SubHeader(string text) Control static

A small group header for splitting a longer settings section into scannable blocks ("Durations", "Breaks"). Place it directly above the rows it introduces.

Hint(string text) TextBlock static

Wrapping 11px explanatory text in the tertiary text colour.

SdkPalette#

public static class SdkPalette

Single source for the hex colours mirrored from the host's theme brushes, used by ContextControls and SettingsControls so both stay in sync.

ConstantValueMirrors
Success#9BE64DThe host has no separate success brush — this reuses AppAccentBrush, which is the same green.
Warning#FFB84DAppWarningBrush
Danger#FF6B6BAppDangerBrush
TextTertiary#525E6BAppTextTertiaryBrush

SyncedContextBase#

public abstract partial class SyncedContextBase<TProject, TTask> : AppContextBase where TProject : class where TTask : class
Tical.SDKTical.Contexts

Base class for contexts that track time against an external service through a two-level picker (project → task) and push completed entries back as worklogs or time entries. You get version-guarded loading, refresh and sync commands, pending-entry status, auto-sync on timer stop, durable per-entry sync records, and the standard slot views.

Constructor
protected SyncedContextBase(IContextHost host, IEntrySyncStore? syncStore = null)

Pass a store only for tests; the default calls CreateSyncStore(), which opens sync.db in a folder named after your Id.

You must implement#

Plus Id and Name, inherited as abstract from AppContextBase.

IsConfigured bool abstract protected

True when the service connection is configured well enough to try talking to it.

AutoSyncEnabled bool abstract protected

Whether completed entries are pushed automatically when the timer stops.

FetchProjectsAsync() Task<IReadOnlyList<TProject>> abstract protected

Fetches the selectable projects. Throw to surface a connection problem in the status line.

FetchTasksAsync(TProject project) Task<IReadOnlyList<TTask>> abstract protected

Fetches the selectable tasks of one project. Throw to surface a connection problem in the status line.

PushEntryAsync(TimeEntry entry) Task<string> abstract protected

Pushes one completed entry to the service and returns the external key it was recorded against — issue key, task id. Throw on failure; the base marks the entry synced immediately after success, so even a crash mid-batch cannot cause duplicates.

TryGetEntryKey(TimeEntry entry, out string key) bool abstract protected

Extracts the external key an entry was stamped with, or returns false when the entry does not belong to this service.

Customization points#

ProjectDisplayText(TProject) · TaskDisplayText(TTask) string virtual protected

What the pickers show. Both default to ToString(), falling back to the empty string.

ProjectKey(TProject project) string virtual protected

Stable identity used to restore the selection across refreshes; defaults to the display text.

TaskKey(TTask task) string virtual protected

Stable identity of a task, used to match a picker selection against the external key an entry was stamped with. Defaults to the display text — override it so it returns exactly that key (Jira: the issue key, ClickUp: the task id). The edit window uses it to preselect the edited entry's task.

ProjectPlaceholder · TaskPlaceholder string virtual protected

Picker placeholders. Default to "Project" and "Task".

NotConfiguredMessage · RefreshTooltip · SyncTooltip string virtual protected

Default to "Configure your {Name} connection in Settings.", "Refresh projects and tasks" and "Sync pending entries to {Name}".

AutoSelectFirst bool virtual protected

When true, the first project/task is selected automatically after a load. Defaults to false.

SearchableTasks bool virtual protected

When true, the task pickers become type-to-search boxes — recommended for services where a project can hold hundreds of tasks. Defaults to false.

RecentTasksFirst bool virtual protected

When true — the default — recently used tasks are listed first. Usage is recorded when a timer run completes against a task and when a task is picked in the New/Edit window; the keys persist in the sync store.

RecentTasksLimit int virtual protected

How many recently used tasks are remembered. Defaults to 10.

IsPendingSync(TimeEntry entry) bool virtual protected

An entry that should be pushed, regardless of whether it already was — the base checks the sync store separately. Defaults to completed (EndTime is not null) and stamped with a key.

ReloadSettingsAsync() Task<bool> virtual protected

Called from OnSettingsChanged. Reload configuration and return true when connection settings changed, so the base can reset the pickers and reload. Defaults to true.

CreateSyncStore() IEntrySyncStore virtual protected

Override to keep a legacy database file name; defaults to sync.db in a folder named after Id.

EntryUrl(TimeEntry entry, string entryKey) string? virtual protected

The web page of the external item an entry is stamped with, powering the "Open in {Name}" row action, or null when none can be built. Jira: the issue's /browse page; ClickUp: the task page. Defaults to null.

MainBarActions IReadOnlyList<Control> virtual protected

Extra buttons for the main bar's action row, placed left of the standard refresh and sync buttons. Empty by default. Build them with ContextControls.IconButton so they match; the base overwrites their Margin for even spacing.

Like BuildView, this must return new control instances on every read — the main bar is built once per window, and a control can only ever have one visual parent.

BuildMainBarView() · BuildCompactInlineView() · BuildCompactExtraView() Control? virtual protected

The standard views. BuildMainBarView is the project and task pickers, MainBarActions, refresh and sync buttons and the status line; BuildCompactInlineView is the selected task's display text; BuildCompactExtraView is small project and task pickers as the compact window's second row.

Edit-window hooks#

CreateEntryEditor is overridden to return a picker-driven editor whose state is deliberately separate from the tracker's, so editing an entry never changes what the main window is tracking. These four hooks shape it.

RequireTaskForNewEntries bool virtual protected

When true — the default — the New-entry window refuses to save until a task is picked while the connection is configured. Entries created without a task could never be pushed to the service.

EditProjectText(TProject? project, TTask task) string? virtual protected

The project text stamped onto the entry when a task is picked in the edit window, or null to leave the entry's project text unchanged. ClickUp writes "list → task" here; Jira keeps the project text for the worklog comment. Defaults to null.

ResolveProjectForEntryKey(string entryKey, IReadOnlyList<TProject> projects) TProject? virtual protected

Finds the project an entry key belongs to so the edit window can preselect it (Jira: "ABC-12" → project "ABC"), or null when it cannot be derived — the window then falls back to the tracker's current project selection.

ApplyEditorSelection(TimeEntry entry, TProject? project, TTask task) void virtual protected

Stamps a project/task selection picked in the New/Edit window onto the entry being saved — issue marker, entry link. Called only when a task is selected; when the pickers are left empty, the entry's existing stamp is kept as is.

What you inherit#

Host IContextHost protected

The host services handed to the constructor.

SyncStore IEntrySyncStore protected

Durable sync bookkeeping, one database per plugin under %LocalAppData%\Tical\Plugins. See Sync store.

Projects · SelectedProject · Tasks · SelectedTask ObservableCollection<T> · T? observable

The picker state. Setting SelectedProject reloads the task list in the background.

IsSyncing bool observable

True while a load or push is in flight; drives the sync button's spinner.

SyncStatus · SyncStatusKind string · SyncStatusKind observable

The status line and its severity. Assigning SyncStatus directly resets the severity to Idle, so an old error colour cannot linger under an unrelated message.

SetSyncStatus(SyncStatusKind kind, string text) void protected

Sets the status line together with its severity. Use this rather than assigning the two properties separately.

RunBackground(Func<Task> work) void protected

Fire-and-forget a background operation without swallowing its exceptions: failures are routed to the status line as an error instead of disappearing silently.

Refresh() · RefreshCommand · Sync() · SyncCommand Task · IAsyncRelayCommand command

Refresh reloads the projects. Sync pushes every pending, not-yet-synced entry one at a time, reporting "Syncing 2/5…" as it goes and summarising with the count of successes and failures.

LoadProjectsAsync() · LoadTasksAsync() Task protected

Version-guarded loads: a superseded call neither overwrites the newer list nor clears its spinner. The project selection is restored by ProjectKey across a reload.

UpdatePendingStatusAsync() Task protected

Full recompute of the pending-sync count from every entry. Called on activation and settings change; the entry hooks adjust the cached count incrementally rather than rescanning.

ShowProject · CompactModeHeight · Filter · BuildView · GetEntryBadge · GetEntryActions · CreateEntryEditor · OnAppStarted · OnActivated · OnSettingsChanged · OnEntryCompleted · OnEntryUpdated · OnEntryDeleted override

Already overridden for you. ShowProject is false (the pickers replace the project row) and CompactModeHeight is 140 (room for the CompactExtra picker row). The badge is "✓" once pushed and "↑" while waiting; the row actions are open-in-browser plus push/re-push. Override further only if you know what the base does.

SyncStatusKind#

public enum SyncStatusKind { Idle, Busy, Success, Warning, Error }

Severity of a tracker's status line, driving its colour in the standard views.

ValueMeaning
IdleResting informational state — "All synced", "3 entries pending sync".
BusyAn operation is in progress — "Syncing 2/5…".
SuccessThe last operation succeeded — "Synced 3 entries ✓".
WarningNeeds attention but nothing failed outright: not configured, partial sync.
ErrorThe last operation failed.

Sync store#

public interface IEntrySyncStore public partial class EntrySyncStore : IEntrySyncStore
Tical.SDK.CoreTical.Services

Durable storage for sync records, entry links and recent tasks. The host database is not accessible to plugins, so this state lives in a database file the plugin owns: %LocalAppData%\Tical\Plugins\<plugin>\<dbFileName>.

The interface is extracted from the implementation so contexts can be unit-tested against a fake store instead of a real SQLite file.

Constructor
public EntrySyncStore(
    string pluginFolderName,
    string dbFileName = "sync.db",
    TimeProvider? timeProvider = null)

Members#

InitializeAsync() Task

Creates the three tables. Call it before anything else — SyncedContextBase does so in OnAppStarted.

IsSyncedAsync(Guid timeEntryId) Task<bool>

Whether the entry has already been pushed.

MarkSyncedAsync(Guid timeEntryId, string externalKey) Task

Records a successful push against an external key.

GetSyncedIdsAsync() Task<HashSet<Guid>>

Every pushed entry id, for a bulk pending calculation.

UnmarkSyncedAsync(Guid timeEntryId) Task

Removes the sync record of an entry, so it counts as pending again and the next sync pushes it once more. A no-op when the entry has no record.

SaveRecentTaskAsync(string taskKey, int keep) Task

Marks a task key as just used and trims the recents to the newest keep.

GetRecentTaskKeysAsync(int limit) Task<List<string>>

The recently used task keys, newest first.

PruneAsync(IEnumerable<Guid> liveEntryIds) Task

Deletes sync records and entry links whose entries are not in liveEntryIds. Call only with the complete entry list — pruning against a partial list, e.g. before a cloud sync finished, loses valid records.

ImportLegacyTableAsync(string tableName, string keyColumn) Task

One-time import from a sync table created by an older plugin version in the same database file. The legacy table must have TimeEntryId and SyncedAt columns; its external-key column name is passed in. The legacy table is dropped after import. Call after InitializeAsync; a no-op when the table does not exist.

Both arguments must be valid SQL identifiers (^[A-Za-z_][A-Za-z0-9_]*$) — EntrySyncStore throws ArgumentException otherwise.

Records#

SyncRecord TimeEntryId · ExternalKey · SyncedAt

Persistent record of a time entry having been pushed. This is the source of truth for duplicate prevention — unlike tags, it is never touched by the wipe-and-reinsert entry save logic.

RecentTaskRecord TaskKey · LastUsedAt

A task the user recently tracked against, kept for most-recently-used ordering of the task picker.

IContextHost#

public interface IContextHost
Tical.SDK.CoreTical.Plugins

Everything the host application exposes to context plugins. Your ITicalPlugin.CreateContexts receives one instance, shared by every context.

SettingsStore IContextSettingsStore required

Persistent storage for context configuration — API keys, URLs, options.

Entries IEntryProvider required

Read access to the user's time entries.

OpenUrl(string url) void required

Opens an http(s) link in the user's default browser — "Open in Jira", token-creation pages, documentation. Other schemes are ignored. Never throws.

Timer ITimerControl? default impl

Control over and events from the app's single timer. Defaults to null on hosts that predate this surface — null-check it.

Notifications IContextNotifications? default impl

Desktop toast notifications. Defaults to null on hosts that predate this surface.

EntryRecorder IEntryRecorder? default impl

Write path for recording sessions outside the normal timer flow. Defaults to null on hosts that predate this surface.

Timer, Notifications and EntryRecorder are default interface members returning null. They were added after the interface shipped, and defaulting them is what keeps older host builds and older plugin implementations compiling. Always null-check before use.

IContextSettingsStore#

public interface IContextSettingsStore

Stores one settings object per context in the local database. Values are encrypted for the current machine user before being written, so secrets such as API tokens are safe to keep here — they are unreadable outside this Windows account, including in any cloud-synced copy of the database.

Load<T>(string contextId) where T : class, new() T required

Loads the settings object saved for the context, or a new default instance when none exists.

Save<T>(string contextId, T settings) where T : class void required

Persists the settings object for the context.

IEntryProvider#

public interface IEntryProvider
GetEntriesAsync() Task<IReadOnlyList<TimeEntry>> required

Returns all saved entries with sessions and tags populated.

GetEntriesAsync(DateTime? from, DateTime? to) Task<IReadOnlyList<TimeEntry>> required

Returns entries whose start time falls in the given range; either bound may be null for an open end. Prefer this over the parameterless overload when a bounded range is all you need — it avoids loading the full history for large ones.

IContextNotifications#

public interface IContextNotifications
Show(string title, string message) void required

Shows a toast notification, subject to the host's "Enable notifications" setting.

Timer & recording#

ITimerControl#

public interface ITimerControl
Tical.SDK.CoreTical.Plugins

Lets a context observe and drive the app's single timer. All events fire for both manual (user clicked start/stop) and programmatic (context-initiated) actions, so a context that started the timer itself still sees its own Started/Stopped.

IsRunning bool

Whether the timer is currently running.

StartTime DateTime?

When the current run started, or null when not running.

Elapsed TimeSpan

Elapsed time of the current run, or zero when not running.

Started event Action<TimerStartedInfo>?

Fires when the timer starts, manually or programmatically.

Stopped event Action<TimerStoppedInfo>?

Fires after the timer has stopped and the resulting entry/session has been merged and saved.

Tick event Action<TimeSpan>?

Forwarded from the host's timer tick, roughly every 200 ms while running.

IdleTick event Action<TimeSpan>?

Forwarded from the host's idle detection while running.

StartAsync() Task<bool>

Starts the timer via the same path as the user's start button. Returns false when no host window is attached yet, or the current state does not allow starting.

StopAsync() Task<bool>

Stops the timer via the same path as the user's stop button — idle prompt, merge, save. Returns false when no host window is attached yet, or nothing is running.

TimerStartedInfo · TimerStoppedInfo#

public sealed record TimerStartedInfo(DateTime StartTime, bool Programmatic); public sealed record TimerStoppedInfo(TimeEntry? Entry, WorkSession? Session, bool Programmatic);

Programmatic is false for a manual user action. On TimerStoppedInfo, Entry and Session are null when the run was too short to record.

IEntryRecorder#

public interface IEntryRecorder

Write path for a context to record a session outside of the normal start/stop timer flow — a Pomodoro break, say — while keeping the host's in-memory entries and groups in sync. Unlike the timer's own stop path, this does not notify other contexts via OnEntryCompleted: it is meant for bookkeeping sessions, not billable work.

RecordSessionAsync(SessionRecord record) Task required

Appends the session to an existing entry (by id or merge match) or a new one, then saves.

SessionRecord#

public sealed record SessionRecord( string Description, string Project, IReadOnlyList<Tag> Tags, DateTime StartTime, DateTime EndTime, string? Label, Guid? AppendToEntryId, string? ContextId = null);

When AppendToEntryId is set, the session is appended to that entry directly; otherwise a merge target is found the same way the timer's stop path does — same day, description, project and tags — falling back to a new entry.

Name yourself in ContextId. The host cannot infer it: IContextHost is a single instance shared by every context, so a context recording a session while it is not the active one — a Pomodoro break finishing after the user switched away — must set this, or the session lands in, and merges into, whichever context happens to be active. Null means "the active context".

Exporters#

public interface IEntryExporter
Tical.SDK.CoreTical.Exporting

An exporter turns a set of time entries into an external representation — a file on disk today (JSON, CSV, …), and in principle any destination. Implement it to add a new "export way" to the Export section of the settings window.

Exporters are standalone: they do not need to be an IAppContext, and they need no host services, so they only reference Tical.SDK.Core. The host discovers every registered exporter and lists it in the export menu, never pattern-matching concrete exporter types.

IEntryExporter#

Id string required

Stable unique identifier, persisted if the last-used exporter is remembered. Never change it between versions.

Name string required

Display name shown in the export menu — "JSON", "CSV".

FileExtension string required

Default file extension without the dot ("json", "csv"), used for the save dialog and the suggested file name. May be empty for exporters that do not write a file.

IconPathData string? required

Optional SVG path data (24×24) rendered as an icon next to the name. Return null for none.

ExportAsync(ExportRequest request, CancellationToken cancellationToken = default) Task required

Writes the requested entries to the destination described by request.

ExportRequest#

public sealed class ExportRequest
FilePath string { get; init; } required

Absolute path the user chose in the save dialog. Empty for non-file exporters.

Entries IReadOnlyList<TimeEntry> { get; init; } required

The entries to export, already filtered by the user's date/workspace/project selection.

WorkspaceNames IReadOnlyDictionary<Guid, string> { get; init; } required

Maps TimeEntry.WorkspaceId to its display name; missing ids should be treated as "Unknown".

A complete exporter#

TsvExporter.cs
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Tical.Exporting;

public sealed class TsvExporter : IEntryExporter
{
    public string Id            => "tsv";
    public string Name          => "TSV";
    public string FileExtension => "tsv";
    public string? IconPathData => null;

    public async Task ExportAsync(ExportRequest request, CancellationToken ct = default)
    {
        var sb = new StringBuilder();
        sb.AppendLine("Date\tWorkspace\tProject\tDescription\tDuration");

        foreach (var entry in request.Entries)
        {
            ct.ThrowIfCancellationRequested();

            var workspace = request.WorkspaceNames.TryGetValue(entry.WorkspaceId, out var n)
                ? n
                : "Unknown";

            sb.AppendLine($"{entry.StartTime:yyyy-MM-dd}\t{workspace}\t{entry.Project}\t" +
                          $"{entry.Description}\t{entry.Duration:hh\\:mm\\:ss}");
        }

        await File.WriteAllTextAsync(request.FilePath, sb.ToString(), ct);
    }
}

Deploying an exporter#

Exporters use a separate discovery mechanism from plugins. Every subfolder of an Exporters root is treated as one exporter package; each DLL directly inside it is scanned for public, non-abstract IEntryExporter implementations, each of which must have a parameterless constructor.

ExporterLoader.DefaultRoots IReadOnlyList<string> static

%LocalAppData%\Tical\Exporters and an Exporters folder next to the running assembly.

ExporterLoader.LoadExporters(IEnumerable<string>? roots = null, Action<string>? log = null) IReadOnlyList<IEntryExporter> static

Loads every exporter found under the given roots, defaulting to DefaultRoots. Failures are logged per assembly and never thrown, so a broken exporter cannot break startup. Each package gets its own isolated load context that resolves shared assemblies from the host.

Entries, sessions & tags#

Tical.SDK.CoreTical.Models

A time entry is a unit of work with a description, project and tags. Its duration is not stored: it is the sum of its work sessions, so pausing and resuming appends a session rather than splitting the entry.

TimeEntry#

public partial class TimeEntry : ObservableObject
Id Guid

Primary key, defaulted to a new Guid.

ContextId string observable

Id of the context that owns this entry, stamped by the host once, when the entry is created. Contexts list their own entries by it. Rows written before this column existed are backfilled to TicalConstants.DefaultContextId.

WorkspaceId Guid observable

The Personal context's sub-scoping within its own entries. Not a substitute for ContextId.

Description · Project string observable

The entry's text. Project is TicalConstants.NoProject when none was set.

Sessions ObservableCollection<WorkSession>

The runs that make up this entry. Mutating the collection, or a session already in it, marks the entry dirty.

Tags ObservableCollection<Tag>

The entry's tags.

StartTime · EndTime · Duration DateTime · DateTime? · TimeSpan computed

Earliest session start, latest session end, and the sum of session durations. StartTime is DateTime.MinValue and EndTime is null when there are no sessions — an EndTime of null is how SyncedContextBase tells a still-running entry from a completed one.

FormattedDuration · TimeRange string computed

hh:mm:ss and "09:00 - 11:30", for display.

PersistedStartTime · PersistedEndTime DateTime?

Persisted snapshots of the computed values, so SQL queries can filter and sort by start time without loading every entry's sessions first. Refresh them with RefreshPersistedTimeRange() before persisting.

ChangeVersion · IsDirty · MarkClean() · MarkClean(int observedVersion) int · bool · void

Dirty tracking for the save path. The overload taking a version captured earlier — before an async save — means a mutation that happened concurrently with the save is not wiped out.

RefreshPersistedTimeRange() void

Recomputes the persisted snapshots from the current sessions. Must be called before persisting the entry.

WorkSession#

public partial class WorkSession : ObservableObject
Id · TimeEntryId Guid

Primary key and the owning entry.

StartTime · EndTime DateTime · DateTime? observable

EndTime is null while the session is still running.

ManualLabel string? observable

Replaces the clock range in the UI — this is what ISessionAnnotator writes.

ManualAdjustment TimeSpan? observable

When set, it is the session's duration, overriding the clock. May be negative, which is how a manual correction subtracts time.

Duration · TimeRange · FormattedDuration TimeSpan · string computed

Duration is ManualAdjustment when set, otherwise (EndTime ?? now) - StartTime — so a running session's duration grows on every read.

Tag#

public partial class Tag : ObservableObject
MaxNameLength const int = 15

Maximum length of a tag's display name; longer names are truncated on assignment.

Id · Name Guid · string

Equality and hashing are by Id alone, so two tags with the same name are still distinct.

TimeEntryTag Id · TimeEntryId · TagId

The entry↔tag join row. Persistence plumbing — you read TimeEntry.Tags, not this.

EntryMerge#

public static class EntryMerge
FindMergeTarget(IEnumerable<TimeEntry> entries, Func<TimeEntry, bool> filter, string description, string project, IEnumerable<Tag> tags, DateTime date) TimeEntry? static

Returns the first entry that passes filter — typically the active context's Filter — and matches on the same day, description, project and tag set, or null when there is no match. Tag comparison is order-insensitive. This is the rule the timer's stop path and IEntryRecorder both use.

Constants & helpers#

TicalConstants#

public static class TicalConstants
Tical.SDK.CoreTical
NoProject const string = "No project"

Placeholder project text stamped onto entries with no project set.

DefaultContextId const string = "Default"

Id of the built-in Personal context. Also the owner every entry written before TimeEntry.ContextId existed is backfilled to, since those entries have always been listed there.

DateTimeExtensions#

public static class DateTimeExtensions
Tical.SDK.CoreTical.Services

Time formatting and arithmetic the host uses; available to plugins so your durations read exactly like the app's.

ToTimerDisplay(this TimeSpan duration) string static

hh:mm:ss, zero-padded, with a leading - for negative spans. Hours are not wrapped at 24. This is what FormattedDuration uses.

TruncateToSeconds(this DateTime dt) DateTime static

Drops sub-second ticks, preserving Kind.

Combine(this DateTime date, TimeSpan time) DateTime static

The date's midnight plus the given time of day.

CalculateContiguousDuration(TimeSpan start, TimeSpan end) · CalculateContiguousDuration(DateTime start, DateTime end) TimeSpan static

The TimeSpan overload treats an end before the start as crossing midnight and adds a day. The DateTime overload cannot — it returns TimeSpan.Zero instead.

RoundUp(this TimeSpan duration, int minutes) TimeSpan static

Rounds up to the next whole minutes increment — the "Fill Up" behaviour. Returns the duration unchanged when minutes is zero or negative, or when it already lands on the increment.

Type index#

Every public type in the two SDK assemblies.

TypeNamespaceAssembly
AppContextBaseTical.ContextsTical.SDK
ContextCapabilityExtensionsTical.ContextsTical.SDK
ContextControlsTical.ContextsTical.SDK
ContextPageTical.ContextsTical.SDK
ContextPageNavigatorTical.ContextsTical.SDK
ContextSettingsSectionTical.ContextsTical.SDK
ContextSettingsViewModelBaseTical.ContextsTical.SDK
ContextViewSlotTical.ContextsTical.SDK
DateTimeExtensionsTical.ServicesTical.SDK.Core
EntryActionTical.ContextsTical.SDK
EntryBadgeTical.ContextsTical.SDK
EntryEditSlotTical.ContextsTical.SDK
EntryEditorTical.ContextsTical.SDK
EntryLinkTical.ServicesTical.SDK.Core
EntryMergeTical.ModelsTical.SDK.Core
EntrySyncStoreTical.ServicesTical.SDK.Core
ExporterLoaderTical.ExportingTical.SDK.Core
ExportRequestTical.ExportingTical.SDK.Core
IAppContextTical.ContextsTical.SDK
IContextHostTical.PluginsTical.SDK.Core
IContextLifecycleTical.ContextsTical.SDK
IContextNavigationTical.ContextsTical.SDK
IContextNotificationsTical.PluginsTical.SDK.Core
IContextSettingsProviderTical.ContextsTical.SDK
IContextSettingsStoreTical.PluginsTical.SDK.Core
IContextViewProviderTical.ContextsTical.SDK
IEntryDecoratorTical.ContextsTical.SDK
IEntryEditorProviderTical.ContextsTical.SDK
IEntryExporterTical.ExportingTical.SDK.Core
IEntryProviderTical.PluginsTical.SDK.Core
IEntryRecorderTical.PluginsTical.SDK.Core
IEntrySyncStoreTical.ServicesTical.SDK.Core
ISessionAnnotatorTical.ContextsTical.SDK
ITicalPluginTical.PluginsTical.SDK
ITimerControlTical.PluginsTical.SDK.Core
RecentTaskRecordTical.ServicesTical.SDK.Core
SdkPaletteTical.ContextsTical.SDK
SessionRecordTical.PluginsTical.SDK.Core
SettingsControlsTical.ContextsTical.SDK
SyncedContextBase<TProject, TTask>Tical.ContextsTical.SDK
SyncRecordTical.ServicesTical.SDK.Core
SyncStatusKindTical.ContextsTical.SDK
TagTical.ModelsTical.SDK.Core
TicalConstantsTicalTical.SDK.Core
TimeEntryTical.ModelsTical.SDK.Core
TimeEntryTagTical.ModelsTical.SDK.Core
TimerStartedInfoTical.PluginsTical.SDK.Core
TimerStoppedInfoTical.PluginsTical.SDK.Core
ValidatedCommandTical.ContextsTical.SDK
ValidationTical.ContextsTical.SDK
ValidationRuleTical.ContextsTical.SDK
WorkSessionTical.ModelsTical.SDK.Core

PlainProjectEditor, SyncedEntryEditor and SettingsControls.IntToDecimalConverter carry XML documentation but are internal or private. They are not part of the public surface and are described here only through the members that return them.