Compare commits

3 Commits

Author SHA1 Message Date
mix 648325a690 Point the About repository link at GitHub.
The Settings About block now links to mixeme/gosentry instead of the private Gitea mirror.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:35:29 +03:00
mix 27927f3ab1 docs: expand README @every schedule syntax
Document supported Go duration units, combinations, cron alternatives for calendar intervals, the one-second tick floor, and cron descriptors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:28:42 +03:00
mix 276539c383 Make the branded GoSentry theme the default.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:18:48 +03:00
10 changed files with 83 additions and 39 deletions
+39 -6
View File
@@ -74,7 +74,7 @@ portable application: moving the program folder also moves its configuration.
"execution_mode": "parallel", "execution_mode": "parallel",
"overlap_policy": "skip", "overlap_policy": "skip",
"default_timeout_seconds": 0, "default_timeout_seconds": 0,
"theme": "default", "theme": "gosentry",
"job_list_view": "detailed" "job_list_view": "detailed"
} }
``` ```
@@ -126,22 +126,55 @@ include the run timestamp and job name:
## Schedules ## Schedules
Interval schedules using Go duration syntax: GoSentry accepts two schedule forms: fixed `@every` intervals and standard
5-field cron expressions.
### `@every` intervals
Write `@every` followed by a [Go duration](https://pkg.go.dev/time#ParseDuration)
— a positive number with a unit suffix. Units can be combined in one value:
```text ```text
@every 10s @every 10s every 10 seconds
@every 5m @every 5m every 5 minutes
@every 1h30m @every 1h every hour
@every 1h30m every hour and a half (same as @every 90m)
@every 2h45m10s hours, minutes, and seconds combined
``` ```
Standard 5-field cron expressions: Supported units:
| Unit | Meaning |
|------|---------|
| `ns` | nanoseconds |
| `us`, `µs` | microseconds |
| `ms` | milliseconds |
| `s` | seconds |
| `m` | minutes |
| `h` | hours |
`@every` does **not** support days, weeks, months, or years — those follow a
calendar, not a fixed interval. For “every day at 02:00”, “on the 1st of each
month”, or “once a year”, use a cron expression (below).
The scheduler checks due jobs once per second, so values shorter than `1s` are
accepted but will not fire faster than once a second.
### Cron expressions
Five fields: minute, hour, day-of-month, month, day-of-week.
```text ```text
*/5 * * * * every five minutes */5 * * * * every five minutes
0 2 * * * every day at 02:00 0 2 * * * every day at 02:00
30 9 * * 1-5 weekdays at 09:30 30 9 * * 1-5 weekdays at 09:30
0 0 1 * * first day of every month at midnight
0 0 1 1 * every year on 1 January at midnight
``` ```
Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
`@monthly`, `@yearly` (and `@annually`, `@midnight`).
## Using The App ## Using The App
1. Start GoSentry. 1. Start GoSentry.
+11
View File
@@ -38,6 +38,10 @@ dragged.**
**Settings:** **Settings:**
- **The branded GoSentry theme is now the default.** Fresh installs, the
**Defaults** button, and configs that omit `theme` all open in the teal/amber
look; users who prefer Fyne's built-in theme can still pick **Default** in
Settings.
- The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as - The **Save / Cancel / Restore defaults** row sits 4 px from the left edge, as
its layout always intended, rather than 8. its layout always intended, rather than 8.
- The caption column is as wide as the widest caption instead of a fixed width, - The caption column is as wide as the widest caption instead of a fixed width,
@@ -51,9 +55,16 @@ dragged.**
the rows above have to give but a dropdown — which paints its box out to the the rows above have to give but a dropdown — which paints its box out to the
row's edge — does not, so the gap collapsed to about a pixel. The Theme row row's edge — does not, so the gap collapsed to about a pixel. The Theme row
now keeps the same gap the checkbox rows have. now keeps the same gap the checkbox rows have.
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
the private Gitea mirror.
**Documentation:** **Documentation:**
- The **README Schedules** section now documents `@every` in full: supported Go
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
`time.ParseDuration`, the fact that days/months/years belong in cron rather
than `@every`, the one-second scheduler tick floor, cron examples for monthly
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
- The **README** describes the application that exists. Its `gosentry.json` - The **README** describes the application that exists. Its `gosentry.json`
sample was three keys short of what the app writes on first run, which made sample was three keys short of what the app writes on first run, which made
the one file the user is invited to hand-edit the least accurate thing in the the one file the user is invited to hand-edit the least accurate thing in the
+1 -1
View File
@@ -514,7 +514,7 @@ Tests the branded theme and the stored theme choice.
|------|---------| |------|---------|
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. | | `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. | | `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
| `TestThemeForChoice` | Verifies the GoSentry choice yields the branded primary and every other value — including the empty legacy one — yields the default theme. | | `TestThemeForChoice` | Verifies the GoSentry choice and the empty legacy value yield the branded primary; only the explicit default choice yields Fyne's built-in theme. |
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. | | `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. |
--- ---
+2 -2
View File
@@ -481,8 +481,8 @@ func validateConfig(config domain.Config) error {
if config.DefaultTimeoutSeconds < 0 { if config.DefaultTimeoutSeconds < 0 {
return errors.New("default timeout must not be negative (0 means no timeout)") return errors.New("default timeout must not be negative (0 means no timeout)")
} }
// Empty Theme is accepted and normalized to the default on load, so older // Empty Theme is accepted and normalized to the branded theme on load, so
// configs (and hand-built ones) stay valid without an explicit theme. // older configs (and hand-built ones) stay valid without an explicit theme.
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry { if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
return errors.New("theme must be 'default' or 'gosentry'") return errors.New("theme must be 'default' or 'gosentry'")
} }
+3 -3
View File
@@ -87,8 +87,8 @@ type Config struct {
// omitempty would hide a deliberate choice from the hand-editable config. // omitempty would hide a deliberate choice from the hand-editable config.
DefaultTimeoutSeconds int `json:"default_timeout_seconds"` DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
Paused bool `json:"paused,omitempty"` Paused bool `json:"paused,omitempty"`
// Theme selects the visual appearance. Empty is treated as ThemeDefault so // Theme selects the visual appearance. Empty is treated as ThemeGoSentry so
// configs written before this field existed keep the original look. // configs written before this field existed pick up the branded look.
Theme Theme `json:"theme,omitempty"` Theme Theme `json:"theme,omitempty"`
// JobListView selects the Jobs list density. Empty is treated as // JobListView selects the Jobs list density. Empty is treated as
// JobListViewDetailed so configs written before this field existed keep the // JobListViewDetailed so configs written before this field existed keep the
@@ -110,7 +110,7 @@ func DefaultConfig() Config {
NotifyOnFailure: true, NotifyOnFailure: true,
ExecutionMode: ExecutionModeParallel, ExecutionMode: ExecutionModeParallel,
OverlapPolicy: OverlapPolicySkip, OverlapPolicy: OverlapPolicySkip,
Theme: ThemeDefault, Theme: ThemeGoSentry,
JobListView: JobListViewDetailed, JobListView: JobListViewDetailed,
DefaultTimeoutSeconds: 0, DefaultTimeoutSeconds: 0,
} }
+1 -1
View File
@@ -118,7 +118,7 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
// the setting impossible to persist. Negative values are rejected by // the setting impossible to persist. Negative values are rejected by
// app.validateConfig before they can be saved. // app.validateConfig before they can be saved.
if config.Theme == "" { if config.Theme == "" {
config.Theme = domain.ThemeDefault config.Theme = domain.ThemeGoSentry
} }
return config, nil return config, nil
} }
+2 -2
View File
@@ -171,8 +171,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
if got.DefaultTimeoutSeconds != 0 { if got.DefaultTimeoutSeconds != 0 {
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds) t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
} }
if got.Theme != domain.ThemeDefault { if got.Theme != domain.ThemeGoSentry {
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault) t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeGoSentry)
} }
if got.JobListView != domain.JobListViewDetailed { if got.JobListView != domain.JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed) t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
+4 -4
View File
@@ -12,7 +12,7 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" const projectRepositoryURL = "https://github.com/mixeme/gosentry"
// settingsCaptions lists every settingsRow caption in the tab, in no // settingsCaptions lists every settingsRow caption in the tab, in no
// particular order. settingsView measures this once with captionColumnWidth // particular order. settingsView measures this once with captionColumnWidth
@@ -263,10 +263,10 @@ const (
) )
func themeLabel(choice domain.Theme) string { func themeLabel(choice domain.Theme) string {
if choice == domain.ThemeGoSentry { if choice == domain.ThemeDefault {
return themeLabelGoSentry
}
return themeLabelDefault return themeLabelDefault
}
return themeLabelGoSentry
} }
func themeFromLabel(label string) domain.Theme { func themeFromLabel(label string) domain.Theme {
+6 -6
View File
@@ -103,14 +103,14 @@ func (t gosentryTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.
func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) } func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) } func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Anything other // themeFor maps a stored Theme choice to a concrete fyne.Theme. Only the
// than the explicit GoSentry choice (including the empty/legacy value) keeps // explicit default choice keeps Fyne's built-in theme; everything else
// Fyne's built-in theme. // (including the empty/legacy value) uses the branded GoSentry theme.
func themeFor(choice domain.Theme) fyne.Theme { func themeFor(choice domain.Theme) fyne.Theme {
if choice == domain.ThemeGoSentry { if choice == domain.ThemeDefault {
return newGoSentryTheme()
}
return theme.DefaultTheme() return theme.DefaultTheme()
}
return newGoSentryTheme()
} }
// applyTheme installs the theme for the given choice on the running app. Fyne // applyTheme installs the theme for the given choice on the running app. Fyne
+13 -13
View File
@@ -54,24 +54,24 @@ func TestGoSentryThemeDelegatesUnbrandedColors(t *testing.T) {
} }
} }
// themeFor maps the stored choice to the right theme: the GoSentry choice yields // themeFor maps the stored choice to the right theme: the GoSentry choice and the
// the branded teal primary; every other value (including the empty legacy value) // empty legacy value yield the branded teal primary; only the explicit default
// yields the default theme, whose primary is not the brand teal. // choice yields Fyne's built-in theme.
func TestThemeForChoice(t *testing.T) { func TestThemeForChoice(t *testing.T) {
gosentry := themeFor(domain.ThemeGoSentry) for _, choice := range []domain.Theme{domain.ThemeGoSentry, ""} {
if got := gosentry.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal { branded := themeFor(choice)
t.Errorf("themeFor(gosentry) primary = %v, want brand teal %v", got, brandTeal) if got := branded.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
t.Errorf("themeFor(%q) primary = %v, want brand teal %v", choice, got, brandTeal)
} }
for _, choice := range []domain.Theme{domain.ThemeDefault, ""} { }
def := themeFor(choice) def := themeFor(domain.ThemeDefault)
if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal { if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
t.Errorf("themeFor(%q) should not use the brand teal primary", choice) t.Errorf("themeFor(default) should not use the brand teal primary")
}
} }
} }
// The dropdown label helpers must round-trip, and the empty/legacy value must map // The dropdown label helpers must round-trip, and the empty/legacy value must map
// to the Default label so the select never shows a blank option. // to the GoSentry label so the select never shows a blank option.
func TestThemeLabelRoundTrip(t *testing.T) { func TestThemeLabelRoundTrip(t *testing.T) {
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry { if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
t.Errorf("round-trip gosentry = %q", got) t.Errorf("round-trip gosentry = %q", got)
@@ -79,7 +79,7 @@ func TestThemeLabelRoundTrip(t *testing.T) {
if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault { if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault {
t.Errorf("round-trip default = %q", got) t.Errorf("round-trip default = %q", got)
} }
if got := themeLabel(""); got != themeLabelDefault { if got := themeLabel(""); got != themeLabelGoSentry {
t.Errorf("empty theme label = %q, want %q", got, themeLabelDefault) t.Errorf("empty theme label = %q, want %q", got, themeLabelGoSentry)
} }
} }