Save to database #13

Merged
haylan merged 42 commits from save-to-database into main 2026-07-12 21:51:57 +00:00
8 changed files with 89 additions and 6 deletions
Showing only changes of commit 9c028aaf5e - Show all commits
+18 -2
View File
@@ -34,8 +34,12 @@ final class GraphController
}
/**
* Query parameters:
* theme string dark|light (default: dark)
* Serves the contribution heatmap SVG, from a 1-hour cache on repeat requests.
*
* Rejects requests to disallowed hosts (ALLOWED_HOSTS env) with a 403.
*
* @param Request $request query parameter `theme` selects "dark" (default) or "light"
* @return Response image/svg+xml body, cacheable for 3600s
*/
#[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])]
public function graph(Request $request): Response
@@ -66,6 +70,12 @@ final class GraphController
]);
}
/**
* Redirects "/" to "/graph.svg", forwarding any query string (e.g. ?theme=light).
*
* @param Request $request incoming request whose query string is preserved
* @return RedirectResponse 302 redirect to /graph.svg
*/
#[Route('/', name: 'index', methods: ['GET'])]
public function index(Request $request): RedirectResponse
{
@@ -75,6 +85,12 @@ final class GraphController
return new RedirectResponse($url, 302);
}
/**
* Reports per-provider connectivity status as JSON.
*
* @return Response 200 with {"status":"ok",...} or 503 with {"status":"degraded",...}
* when any configured provider's probe reports an error
*/
#[Route('/health', name: 'health', methods: ['GET'])]
public function health(): Response
{
+6
View File
@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace GitContributionGraph\Entity;
/** A single provider's contribution count for one day, as stored in {@see \GitContributionGraph\Service\ContributionStore}. */
final class Contribution
{
/**
* @param string $provider provider identifier, e.g. "github"
* @param int $date day of the contribution, as a unix timestamp
* @param int $count contribution count for that day
*/
public function __construct(
public readonly string $provider,
public readonly int $date,
+22 -1
View File
@@ -15,6 +15,9 @@ class ContributionStore
/**
* Opens (creating if needed) the SQLite store at $dbPath and ensures the schema exists.
*
* @param string $dbPath filesystem path to the SQLite database file
* @param ?int $retentionDays days of history to keep; null or 0 keeps rows forever
*/
public function __construct(
private readonly string $dbPath = "%kernel.project_dir%/var/data/contributions.db",
@@ -43,6 +46,10 @@ class ContributionStore
/**
* Inserts a contribution count, overwriting any existing count for the same provider/date.
*
* @param string $provider provider identifier, e.g. "github"
* @param int $unixtime day of the contribution, as a unix timestamp
* @param int $count contribution count for that day
*/
public function add(string $provider, int $unixtime, int $count): void
{
@@ -56,6 +63,9 @@ class ContributionStore
/**
* Deletes the contribution row for the given provider/date, if any.
*
* @param string $provider provider identifier, e.g. "github"
* @param int $unixtime day to delete, as a unix timestamp
*/
public function remove(string $provider, int $unixtime): void
{
@@ -69,6 +79,7 @@ class ContributionStore
/**
* Upserts a batch of date => count pairs for a provider via repeated add() calls.
*
* @param string $provider provider identifier, e.g. "github"
* @param array<int, int> $dateCounts unix timestamp => count
*/
public function merge(string $provider, array $dateCounts): void
@@ -80,6 +91,9 @@ class ContributionStore
/**
* Returns the most recent stored unix timestamp for a provider, or null if it has no rows.
*
* @param string $provider provider identifier, e.g. "github"
* @return ?int unix timestamp of the latest stored day, or null if none stored yet
*/
public function latestDate(string $provider): ?int
{
@@ -92,6 +106,10 @@ class ContributionStore
/**
* Returns all stored contributions for a provider, optionally limited to the last $sinceDays days.
*
* @param string $provider provider identifier, e.g. "github"
* @param ?int $sinceDays if set, only rows from the last N days are returned
* @return ContributionCollection
*/
public function all(string $provider, ?int $sinceDays = null): ContributionCollection
{
@@ -106,9 +124,12 @@ class ContributionStore
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
/** @var array<int, array{provider: string, date: int|string, count: int|string}> $rows */
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$contributions = array_map(
static fn(array $row): Contribution => new Contribution($row['provider'], (int) $row['date'], (int) $row['count']),
$stmt->fetchAll(PDO::FETCH_ASSOC),
$rows,
);
return new ContributionCollection(...$contributions);
+4
View File
@@ -9,6 +9,10 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
trait ProbeTrait
{
/**
* Reports NotConfigured if credentials are missing, otherwise pings the provider
* and reports Ok or Error (with a classified error code and message).
*/
public function probe(): ProviderStatus
{
if (!$this->isConfigured()) {
@@ -8,13 +8,17 @@ use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
final class ProviderHealthChecker
{
/** @param iterable<ProviderInterface> $providers */
public function __construct(
#[AutowireIterator('app.provider')]
private readonly iterable $providers,
) {}
/**
* @return array{status: string, providers: array<string, array<string, string>>}
* Probes every configured provider and aggregates the results for the /health endpoint.
*
* @return array{status: string, providers: array<string, array<string, string>>} 'status' is
* "degraded" if any provider's probe() reported an error, otherwise "ok"
*/
public function check(): array
{
+12 -1
View File
@@ -4,8 +4,15 @@ declare(strict_types=1);
namespace GitContributionGraph\Service\Provider;
/** Result of probing a single provider, as reported by the /health endpoint. */
final class ProviderStatus
{
/**
* @param string $name provider identifier, e.g. "github"
* @param ProviderStatusType $status overall outcome of the probe
* @param ?ProviderErrorCode $error classified error code, set only when $status is Error
* @param ?string $message human-readable error detail, set only when $status is Error
*/
public function __construct(
public readonly string $name,
public readonly ProviderStatusType $status,
@@ -13,7 +20,11 @@ final class ProviderStatus
public readonly ?string $message = null,
) {}
/** @return array<string, string> */
/**
* Converts to the array shape used in the /health JSON response, omitting error/message when unset.
*
* @return array<string, string>
*/
public function toArray(): array
{
$data = ['status' => $this->status->value];
+19
View File
@@ -36,6 +36,12 @@ final class SvgRenderer
private const MARGIN_Y = 20; // top margin for month labels
private const PADDING = 10; // outer padding
/**
* Builds the full self-contained SVG heatmap for a contribution map.
*
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @param string $theme "dark" or "light"; unknown values fall back to "dark"
*/
public function render(array $contributions, string $theme = 'dark'): string
{
$colors = self::THEMES[$theme] ?? self::THEMES['dark'];
@@ -91,6 +97,9 @@ final class SvgRenderer
/**
* Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int]
* or null if the date is in the future.
*
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array<int, array<int, array{date: string, count: int}|null>>
*/
private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array
{
@@ -125,6 +134,10 @@ final class SvgRenderer
};
}
/**
* @param array<string, int> $contributions date (Y-m-d) => contribution count
* @return array{total: int}
*/
private function computeStats(array $contributions): array
{
return ['total' => array_sum($contributions)];
@@ -132,6 +145,7 @@ final class SvgRenderer
// -------------------------------------------------------------------------
/** @param array<int, array<int, array{date: string, count: int}|null>> $grid */
private function renderMonthLabels(array $grid, string $textColor): string
{
$out = '';
@@ -183,6 +197,10 @@ final class SvgRenderer
return $out;
}
/**
* @param array<int, array<int, array{date: string, count: int}|null>> $grid
* @param array{bg: string, text: string, levels: array<int, string>} $colors
*/
private function renderCells(array $grid, array $colors): string
{
$out = '';
@@ -218,6 +236,7 @@ final class SvgRenderer
return $out;
}
/** @param array{bg: string, text: string, levels: array<int, string>} $colors */
private function renderLegend(int $totalH, array $colors): string
{
$y = $totalH - 14;
@@ -54,6 +54,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString($expectedColor, $svg);
}
/** @return iterable<string, array{string, string}> */
public static function theme_background_provider(): iterable
{
yield 'dark theme' => ['dark', '#0d1117'];
@@ -97,6 +98,7 @@ final class SvgRendererTest extends TestCase
$this->assertStringContainsString('>' . $label . '<', $svg);
}
/** @return iterable<string, array{string}> */
public static function day_of_week_label_provider(): iterable
{
yield 'Monday' => ['Mon'];