Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions github-activity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# GitHub Activity

A native GitHub contribution calendar for the Noctalia v5 bar. It displays
today's contributions in a compact widget and opens an adaptive, theme-aware
annual heatmap on click.

![GitHub Activity panel](thumbnail.webp)

## Plugin

| Field | Value |
| --- | --- |
| Plugin ID | `alexmnrs/github-activity` |
| Entries | Bar widget: `activity`; panel: `calendar`; service: `sync` |
| Minimum Noctalia plugin API | `24` (Noctalia v5.0.0-beta.9) |

## Requirements

- [GitHub CLI](https://cli.github.com/) (`gh`), authenticated with the account
whose activity you want to display.
- `xdg-open` (`xdg-utils` on Arch) to open the profile button.

Authenticate once before enabling the plugin:

```bash
gh auth login -h github.com
```

## Usage

1. Enable **GitHub Activity** in Noctalia's plugin manager.
2. Add the `activity` widget to a bar from the widget picker.
3. Left-click the widget to open the annual calendar.
4. Right-click the widget or use the panel's refresh button to refresh now.

## Settings

| Setting | Options | Default |
| --- | --- | --- |
| Automatic refresh interval | 15, 30, or 60 minutes | 30 minutes |

Changing the interval applies it immediately and requests a refresh unless a
request is already in progress. The widget tooltip and calendar panel show the
last update time and selected automatic refresh interval.

The panel can also be toggled through IPC:

```bash
noctalia msg panel-toggle alexmnrs/github-activity:calendar
```

Request a refresh externally with:

```bash
noctalia msg plugin alexmnrs/github-activity:sync all refresh
```

## Data and privacy

The plugin runs `gh api graphql` to request the authenticated account's
`contributionCalendar`. It never reads, writes, or displays a GitHub token.
Authentication remains entirely inside GitHub CLI.

The latest successful normalized calendar is cached in Noctalia's per-plugin
data directory. The cache contains the public contribution dates, counts,
levels, username, and fetch time; it contains no credentials. It lets the
widget continue to show the last known activity when offline.

## Troubleshooting

- **GitHub CLI is required:** install `github-cli` (or your distribution's
`gh` package).
- **GitHub CLI needs authentication:** run `gh auth login -h github.com`.
- **No data after a refresh:** run `gh auth status` in a terminal, then retry.
- **The profile button does nothing:** install `xdg-utils` so `xdg-open` is
available.

## Compatibility

Noctalia v5's plugin API is beta and can change before the stable release. This
plugin targets API 24 and uses Noctalia's declarative panel and shared-state
APIs only.
223 changes: 223 additions & 0 deletions github-activity/lib/activity.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
--!nonstrict

local Activity = {}

local levelByName = {
NONE = 0,
FIRST_QUARTILE = 1,
SECOND_QUARTILE = 2,
THIRD_QUARTILE = 3,
FOURTH_QUARTILE = 4,
}

local colorByLevel = {
"surface_variant",
"primary/0.25",
"primary/0.45",
"primary/0.70",
"primary",
}

function Activity.level(value)
if type(value) == "number" then
return math.clamp(math.floor(value), 0, 4)
end

return levelByName[value] or 0
end

function Activity.color(level)
return colorByLevel[Activity.level(level) + 1]
end

function Activity.symbol(level)
local symbols = { "·", "░", "▒", "▓", "█" }
return symbols[Activity.level(level) + 1]
end

function Activity.rowLevels(row)
local levels = {}
local byCodepoint = {
[0x00B7] = 0,
[0x2591] = 1,
[0x2592] = 2,
[0x2593] = 3,
[0x2588] = 4,
}
for _, codepoint in utf8.codes(row or "") do
table.insert(levels, byCodepoint[codepoint] or 0)
end
return levels
end

function Activity.flatten(weeks)
local days = {}

for _, week in ipairs(weeks or {}) do
for _, day in ipairs(week.days or {}) do
table.insert(days, day)
end
end

return days
end

function Activity.streaks(days, todayDate)
local best = 0
local running = 0

for _, day in ipairs(days or {}) do
if (day.count or 0) > 0 then
running = running + 1
best = math.max(best, running)
else
running = 0
end
end

local endIndex = nil
for index = #days, 1, -1 do
if days[index].date <= todayDate then
endIndex = index
break
end
end

if endIndex == nil then
return 0, best
end

-- A day that has not happened yet is irrelevant. If today is empty, retain
-- yesterday's active streak rather than resetting it early in the morning.
if days[endIndex].date == todayDate and (days[endIndex].count or 0) == 0 then
endIndex = endIndex - 1
end

local current = 0
for index = endIndex, 1, -1 do
if (days[index].count or 0) == 0 then
break
end
current = current + 1
end

return current, best
end

function Activity.normalize(payload, todayDate)
local viewer = payload
and payload.data
and payload.data.viewer
local calendar = viewer
and viewer.contributionsCollection
and viewer.contributionsCollection.contributionCalendar

if type(viewer) ~= "table" or type(calendar) ~= "table" then
return nil, "missing contribution calendar"
end

if type(viewer.login) ~= "string"
or type(calendar.weeks) ~= "table"
or type(calendar.totalContributions) ~= "number" then
return nil, "invalid contribution calendar"
end

local weeks = {}
for _, sourceWeek in ipairs(calendar.weeks) do
if type(sourceWeek.contributionDays) ~= "table" then
return nil, "invalid contribution week"
end

local week = { days = {} }
for _, sourceDay in ipairs(sourceWeek.contributionDays) do
if type(sourceDay.date) ~= "string" or type(sourceDay.contributionCount) ~= "number" then
return nil, "invalid contribution day"
end

table.insert(week.days, {
date = sourceDay.date,
count = math.max(0, math.floor(sourceDay.contributionCount)),
level = Activity.level(sourceDay.contributionLevel),
})
end
table.insert(weeks, week)
end

local days = Activity.flatten(weeks)
local today = 0
for _, day in ipairs(days) do
if day.date == todayDate then
today = day.count
break
end
end

local currentStreak, bestStreak = Activity.streaks(days, todayDate)
return {
login = viewer.login,
total = math.max(0, math.floor(calendar.totalContributions)),
weeks = weeks,
today = today,
currentStreak = currentStreak,
bestStreak = bestStreak,
fetchedAt = os.time(),
}
end

-- Convert caches written by the first MVP build (which stored all 371 days)
-- into the compact representation used by the CPU-safe panel.
function Activity.compact(data, todayDate)
if type(data) ~= "table" then
return nil
end

if type(data.rows) == "table" then
return data
end

if type(data.weeks) ~= "table"
or type(data.login) ~= "string"
or type(data.total) ~= "number" then
return nil
end

local rows = { "", "", "", "", "", "", "" }
for _, week in ipairs(data.weeks) do
if type(week) ~= "table" or type(week.days) ~= "table" then
return nil
end

for index = 1, 7 do
local day = week.days[index]
if day ~= nil then
rows[index] = rows[index] .. Activity.symbol(day.level)
end
end
end

return {
login = data.login,
total = data.total,
rows = rows,
today = data.today or 0,
todayDate = data.todayDate or todayDate or os.date("%Y-%m-%d"),
currentStreak = data.currentStreak or 0,
bestStreak = data.bestStreak or 0,
fetchedAt = data.fetchedAt or os.time(),
}
end

function Activity.isValidCache(data)
return type(data) == "table"
and type(data.login) == "string"
and type(data.total) == "number"
and type(data.rows) == "table"
and (data.details == nil or type(data.details) == "table")
and type(data.today) == "number"
and type(data.todayDate) == "string"
and type(data.currentStreak) == "number"
and type(data.bestStreak) == "number"
and type(data.fetchedAt) == "number"
end

return Activity
21 changes: 21 additions & 0 deletions github-activity/lib/settings.luau
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
--!nonstrict

local Settings = {}

local DEFAULT_REFRESH_INTERVAL_MINUTES = 30
local refreshIntervalMinutes = {
["15"] = 15,
["30"] = 30,
["60"] = 60,
}

function Settings.refreshIntervalMinutes(value)
return type(value) == "string" and refreshIntervalMinutes[value]
or DEFAULT_REFRESH_INTERVAL_MINUTES
end

function Settings.refreshIntervalMs(value)
return Settings.refreshIntervalMinutes(value) * 60 * 1000
end

return Settings
Loading