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.
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.
<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:
<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.
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#
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#
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#
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.
| Interface | Adds |
|---|---|
| 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#
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#
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.
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.
Lifecycle#
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.
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#
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
CompactModeHeightwhen 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.
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.
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#
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#
Reach it with host.Navigation() from
ContextCapabilityExtensions,
which returns null on hosts that predate this surface.
ContextPageNavigator#
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.
New/Edit entry window#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
Single source for the hex colours mirrored from the host's theme brushes, used by
ContextControls and SettingsControls so both stay in sync.
| Constant | Value | Mirrors |
|---|---|---|
| Success | #9BE64D | The host has no separate success brush — this reuses AppAccentBrush, which is the same green. |
| Warning | #FFB84D | AppWarningBrush |
| Danger | #FF6B6B | AppDangerBrush |
| TextTertiary | #525E6B | AppTextTertiaryBrush |
SyncedContextBase#
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.
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#
Severity of a tracker's status line, driving its colour in the standard views.
| Value | Meaning |
|---|---|
| Idle | Resting informational state — "All synced", "3 entries pending sync". |
| Busy | An operation is in progress — "Syncing 2/5…". |
| Success | The last operation succeeded — "Synced 3 entries ✓". |
| Warning | Needs attention but nothing failed outright: not configured, partial sync. |
| Error | The last operation failed. |
Sync store#
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.
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.
SaveLinkAsync(Guid timeEntryId, string externalKey)
Task
Maps an entry to the external item it was created against.
GetLinksAsync()
Task<Dictionary<Guid, string>>
All entry links, keyed by entry id.
DeleteLinkAsync(Guid timeEntryId)
Task
Removes the entry link of an entry. A no-op when the entry has none.
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.
EntryLink
TimeEntryId · ExternalKey
Maps a time entry to the external item it was created against. Unlike
SyncRecord this is written when the entry is stamped, not when
it is pushed — it lets a context keep the external id out of the entry's visible
text.
RecentTaskRecord
TaskKey · LastUsedAt
A task the user recently tracked against, kept for most-recently-used ordering of the task picker.
IContextHost#
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#
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#
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#
Show(string title, string message)
void
required
Shows a toast notification, subject to the host's "Enable notifications" setting.
Timer & recording#
ITimerControl#
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#
Programmatic is false for a manual user action. On
TimerStoppedInfo, Entry and Session are null when
the run was too short to record.
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#
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.
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#
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#
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#
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#
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#
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#
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#
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#
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#
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#
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.
| Type | Namespace | Assembly |
|---|---|---|
| AppContextBase | Tical.Contexts | Tical.SDK |
| ContextCapabilityExtensions | Tical.Contexts | Tical.SDK |
| ContextControls | Tical.Contexts | Tical.SDK |
| ContextPage | Tical.Contexts | Tical.SDK |
| ContextPageNavigator | Tical.Contexts | Tical.SDK |
| ContextSettingsSection | Tical.Contexts | Tical.SDK |
| ContextSettingsViewModelBase | Tical.Contexts | Tical.SDK |
| ContextViewSlot | Tical.Contexts | Tical.SDK |
| DateTimeExtensions | Tical.Services | Tical.SDK.Core |
| EntryAction | Tical.Contexts | Tical.SDK |
| EntryBadge | Tical.Contexts | Tical.SDK |
| EntryEditSlot | Tical.Contexts | Tical.SDK |
| EntryEditor | Tical.Contexts | Tical.SDK |
| EntryLink | Tical.Services | Tical.SDK.Core |
| EntryMerge | Tical.Models | Tical.SDK.Core |
| EntrySyncStore | Tical.Services | Tical.SDK.Core |
| ExporterLoader | Tical.Exporting | Tical.SDK.Core |
| ExportRequest | Tical.Exporting | Tical.SDK.Core |
| IAppContext | Tical.Contexts | Tical.SDK |
| IContextHost | Tical.Plugins | Tical.SDK.Core |
| IContextLifecycle | Tical.Contexts | Tical.SDK |
| IContextNavigation | Tical.Contexts | Tical.SDK |
| IContextNotifications | Tical.Plugins | Tical.SDK.Core |
| IContextSettingsProvider | Tical.Contexts | Tical.SDK |
| IContextSettingsStore | Tical.Plugins | Tical.SDK.Core |
| IContextViewProvider | Tical.Contexts | Tical.SDK |
| IEntryDecorator | Tical.Contexts | Tical.SDK |
| IEntryEditorProvider | Tical.Contexts | Tical.SDK |
| IEntryExporter | Tical.Exporting | Tical.SDK.Core |
| IEntryProvider | Tical.Plugins | Tical.SDK.Core |
| IEntryRecorder | Tical.Plugins | Tical.SDK.Core |
| IEntrySyncStore | Tical.Services | Tical.SDK.Core |
| ISessionAnnotator | Tical.Contexts | Tical.SDK |
| ITicalPlugin | Tical.Plugins | Tical.SDK |
| ITimerControl | Tical.Plugins | Tical.SDK.Core |
| RecentTaskRecord | Tical.Services | Tical.SDK.Core |
| SdkPalette | Tical.Contexts | Tical.SDK |
| SessionRecord | Tical.Plugins | Tical.SDK.Core |
| SettingsControls | Tical.Contexts | Tical.SDK |
| SyncedContextBase<TProject, TTask> | Tical.Contexts | Tical.SDK |
| SyncRecord | Tical.Services | Tical.SDK.Core |
| SyncStatusKind | Tical.Contexts | Tical.SDK |
| Tag | Tical.Models | Tical.SDK.Core |
| TicalConstants | Tical | Tical.SDK.Core |
| TimeEntry | Tical.Models | Tical.SDK.Core |
| TimeEntryTag | Tical.Models | Tical.SDK.Core |
| TimerStartedInfo | Tical.Plugins | Tical.SDK.Core |
| TimerStoppedInfo | Tical.Plugins | Tical.SDK.Core |
| ValidatedCommand | Tical.Contexts | Tical.SDK |
| Validation | Tical.Contexts | Tical.SDK |
| ValidationRule | Tical.Contexts | Tical.SDK |
| WorkSession | Tical.Models | Tical.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.