Repository: DenverCoder1/github-readme-streak-stats (php)

Issue:
Weekly contribution mode
**Is your feature request related to a problem? Please describe.**

Contributing to GitHub every day is a huge commitment.

Some users may want the option to take off on the weekends or at other busy times during their week due to other life commitments.

Having only the option to show a daily streak makes it so that people who can't contribute every day won't have a  streak to show off.

**Describe the solution you'd like**

Add a "Week Streak" mode, toggle-able from the url options that will display the number of consecutive weeks instead of days.

This will allow more users to have a streak they are proud to display.

Pull request description:
feat: Add weekly contribution mode
## Description

Closes #196 

### Type of change

<!-- Please delete options that are not relevant. -->

- [ ] Bug fix (added a non-breaking change which fixes an issue)
- [x] New feature (added a non-breaking change which adds functionality)
- [ ] Updated documentation (updated the readme, templates, or other repo files)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## How Has This Been Tested?

<!--
If you have changed a feature of the stats cards, please describe the tests you made to verify your changes.
Changes strictly related to documentation can skip this section.
-->

- [x] Tested locally with a valid username
- [x] Ran tests with `composer test`
- [x] Added or updated test cases to test new features

## Checklist:

- [x] I have checked to make sure no other [pull requests](https://github.com/DenverCoder1/github-readme-streak-stats/pulls?q=is%3Apr+sort%3Aupdated-desc+) are open for this issue
- [x] The code is properly formatted and is consistent with the existing code style
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings

## Screenshots

![image](https://user-images.githubusercontent.com/20955511/204968104-7f791690-9aae-46ba-b4a9-78c2d516313e.png)


Patch:
diff --git a/README.md b/README.md
index b39364a..507499f 100644
--- a/README.md
+++ b/README.md
@@ -43,25 +43,26 @@ The `user` field is the only required option. All other fields are optional.
 
 If the `theme` parameter is specified, any color customizations specified will be applied on top of the theme, overriding the theme's values.
 
-|     Parameter     |                     Details                     |                              Example                              |
-| :---------------: | :---------------------------------------------: | :---------------------------------------------------------------: |
-|      `user`       |        GitHub username to show stats for        |                          `DenverCoder1`                           |
-|      `theme`      |     The theme to apply (Default: `default`)     |          `dark`, `radical`, etc. [🎨➜](./docs/themes.md)          |
-|   `hide_border`   | Make the border transparent (Default: `false`)  |                         `true` or `false`                         |
-|  `border_radius`  | Set the roundness of the edges (Default: `4.5`) |           Number `0` (sharp corners) to `248` (ellipse)           |
-|   `background`    |                Background color                 |             **hex code** without `#` or **css color**             |
-|     `border`      |                  Border color                   |             **hex code** without `#` or **css color**             |
-|     `stroke`      |       Stroke line color between sections        |             **hex code** without `#` or **css color**             |
-|      `ring`       |   Color of the ring around the current streak   |             **hex code** without `#` or **css color**             |
-|      `fire`       |          Color of the fire in the ring          |             **hex code** without `#` or **css color**             |
-|  `currStreakNum`  |              Current streak number              |             **hex code** without `#` or **css color**             |
-|    `sideNums`     |        Total and longest streak numbers         |             **hex code** without `#` or **css color**             |
-| `currStreakLabel` |              Current streak label               |             **hex code** without `#` or **css color**             |
-|   `sideLabels`    |         Total and longest streak labels         |             **hex code** without `#` or **css color**             |
-|      `dates`      |              Date range text color              |             **hex code** without `#` or **css color**             |
-|   `date_format`   |        Date format (Default: `M j[, Y]`)        |          See note below on [Date Formats](#date-formats)          |
-|     `locale`      |    Locale to use for labels (Default: `en`)     | ISO 639-1 code (See [`translations.php`](./src/translations.php)) |
-|      `type`       |         Output format (Default: `svg`)          |              Current options: `svg`, `png` or `json`              |
+|     Parameter     |                     Details                     |                                  Example                                  |
+| :---------------: | :---------------------------------------------: | :-----------------------------------------------------------------------: |
+|      `user`       |        GitHub username to show stats for        |                              `DenverCoder1`                               |
+|      `theme`      |     The theme to apply (Default: `default`)     |              `dark`, `radical`, etc. [🎨➜](./docs/themes.md)              |
+|   `hide_border`   | Make the border transparent (Default: `false`)  |                             `true` or `false`                             |
+|  `border_radius`  | Set the roundness of the edges (Default: `4.5`) |               Number `0` (sharp corners) to `248` (ellipse)               |
+|   `background`    |                Background color                 |                 **hex code** without `#` or **css color**                 |
+|     `border`      |                  Border color                   |                 **hex code** without `#` or **css color**                 |
+|     `stroke`      |       Stroke line color between sections        |                 **hex code** without `#` or **css color**                 |
+|      `ring`       |   Color of the ring around the current streak   |                 **hex code** without `#` or **css color**                 |
+|      `fire`       |          Color of the fire in the ring          |                 **hex code** without `#` or **css color**                 |
+|  `currStreakNum`  |              Current streak number              |                 **hex code** without `#` or **css color**                 |
+|    `sideNums`     |        Total and longest streak numbers         |                 **hex code** without `#` or **css color**                 |
+| `currStreakLabel` |              Current streak label               |                 **hex code** without `#` or **css color**                 |
+|   `sideLabels`    |         Total and longest streak labels         |                 **hex code** without `#` or **css color**                 |
+|      `dates`      |              Date range text color              |                 **hex code** without `#` or **css color**                 |
+|   `date_format`   |        Date format (Default: `M j[, Y]`)        |              See note below on [Date Formats](#date-formats)              |
+|     `locale`      |    Locale to use for labels (Default: `en`)     |     ISO 639-1 code (See [`translations.php`](./src/translations.php))     |
+|      `type`       |         Output format (Default: `svg`)          |                  Current options: `svg`, `png` or `json`                  |
+|      `mode`       |         Streak mode (Default: `daily`)          | `daily` (contribute daily) or `weekly` (contribute once per Sun-Sat week) |
 
 ## 🖌 Themes
 
diff --git a/src/card.php b/src/card.php
index bb23818..e81249d 100644
--- a/src/card.php
+++ b/src/card.php
@@ -243,8 +243,13 @@ function generateCard(array $stats, array $params = null): string
 
     // if the translations contain a newline, split the text into two tspan elements
     $totalContributionsText = splitLines($localeTranslations["Total Contributions"], 24, -9);
-    $currentStreakText = splitLines($localeTranslations["Current Streak"], 22, -9);
-    $longestStreakText = splitLines($localeTranslations["Longest Streak"], 24, -9);
+    if ($stats["mode"] === "weekly") {
+        $currentStreakText = splitLines($localeTranslations["Week Streak"], 22, -9);
+        $longestStreakText = splitLines($localeTranslations["Longest Week Streak"], 24, -9);
+    } else {
+        $currentStreakText = splitLines($localeTranslations["Current Streak"], 22, -9);
+        $longestStreakText = splitLines($localeTranslations["Longest Streak"], 24, -9);
+    }
 
     // if the ranges contain over 28 characters, split the text into two tspan elements
     $totalContributionsRange = splitLines($totalContributionsRange, 28, 0);
diff --git a/src/demo/index.php b/src/demo/index.php
index dd1bc8e..1ab7711 100644
--- a/src/demo/index.php
+++ b/src/demo/index.php
@@ -128,6 +128,12 @@ function camel_to_skewer(string $str): string
                     <option value="[Y.]n.j">2016.8.10</option>
                 </select>
 
+                <label for="mode">Streak Mode</label>
+                <select class="param" id="mode" name="mode">
+                    <option value="daily">Daily</option>
+                    <option value="weekly">Weekly</option>
+                </select>
+
                 <details class="advanced">
                     <summary>⚙ Advanced Options</summary>
                     <div class="content parameters">
diff --git a/src/demo/js/script.js b/src/demo/js/script.js
index feefb4a..9e356e0 100644
--- a/src/demo/js/script.js
+++ b/src/demo/js/script.js
@@ -11,6 +11,7 @@ const preview = {
     date_format: "",
     locale: "en",
     border_radius: "4.5",
+    mode: "daily",
   },
 
   /**
diff --git a/src/demo/preview.php b/src/demo/preview.php
index 941b33a..e2f95b4 100644
--- a/src/demo/preview.php
+++ b/src/demo/preview.php
@@ -3,9 +3,13 @@
 declare(strict_types=1);
 
 require_once "../card.php";
+require_once "../stats.php";
+
+$mode = $_GET["mode"] ?? "daily";
 
 // generate demo stats
 $demoStats = [
+    "mode" => "daily",
     "totalContributions" => 2048,
     "firstContribution" => "2016-08-10",
     "longestStreak" => [
@@ -20,6 +24,20 @@ $demoStats = [
     ],
 ];
 
+if ($mode == "weekly") {
+    $demoStats["mode"] = "weekly";
+    $demoStats["longestStreak"] = [
+        "start" => "2021-12-19",
+        "end" => "2022-03-13",
+        "length" => 13,
+    ];
+    $demoStats["currentStreak"] = [
+        "start" => getPreviousSunday(date("Y-m-d", strtotime("-15 days"))),
+        "end" => getPreviousSunday(date("Y-m-d")),
+        "length" => 3,
+    ];
+}
+
 // set content type to SVG image
 header("Content-Type: image/svg+xml");
 
diff --git a/src/index.php b/src/index.php
index ea72350..da008ce 100644
--- a/src/index.php
+++ b/src/index.php
@@ -34,7 +34,11 @@ try {
     // get streak stats for user given in query string
     $contributionGraphs = getContributionGraphs($_REQUEST["user"]);
     $contributions = getContributionDates($contributionGraphs);
-    $stats = getContributionStats($contributions);
+    if (isset($_GET["mode"]) && $_GET["mode"] === "weekly") {
+        $stats = getWeeklyContributionStats($contributions);
+    } else {
+        $stats = getContributionStats($contributions);
+    }
     renderOutput($stats);
 } catch (InvalidArgumentException | AssertionError $error) {
     renderOutput($error->getMessage(), $error->getCode());
diff --git a/src/stats.php b/src/stats.php
index e2b78f4..72146a0 100644
--- a/src/stats.php
+++ b/src/stats.php
@@ -216,7 +216,7 @@ function getContributionDates(array $contributionGraphs): array
 }
 
 /**
- * Get a stats array with the contribution count, streak, and dates
+ * Get a stats array with the contribution count, daily streak, and dates
  *
  * @param array<string, int> $contributions Y-M-D contribution dates with contribution counts
  * @return array<string, mixed> Streak stats
@@ -230,6 +230,7 @@ function getContributionStats(array $contributions): array
     $today = array_key_last($contributions);
     $first = array_key_first($contributions);
     $stats = [
+        "mode" => "daily",
         "totalContributions" => 0,
         "firstContribution" => "",
         "longestStreak" => [
@@ -279,3 +280,94 @@ function getContributionStats(array $contributions): array
     }
     return $stats;
 }
+
+/**
+ * Get the previous Sunday of a given date
+ *
+ * @param string $date Date to get previous Sunday of (Y-m-d)
+ * @return string Previous Sunday
+ */
+function getPreviousSunday(string $date): string
+{
+    $dayOfWeek = date("w", strtotime($date));
+    return date("Y-m-d", strtotime("-$dayOfWeek days", strtotime($date)));
+}
+
+/**
+ * Get a stats array with the contribution count, weekly streak, and dates
+ *
+ * @param array<string, int> $contributions Y-M-D contribution dates with contribution counts
+ * @return array<string, mixed> Streak stats
+ */
+function getWeeklyContributionStats(array $contributions): array
+{
+    // if no contributions, display error
+    if (empty($contributions)) {
+        throw new AssertionError("No contributions found.", 204);
+    }
+    $thisWeek = getPreviousSunday(array_key_last($contributions));
+    $first = array_key_first($contributions);
+    $firstWeek = getPreviousSunday($first);
+    $stats = [
+        "mode" => "weekly",
+        "totalContributions" => 0,
+        "firstContribution" => "",
+        "longestStreak" => [
+            "start" => $firstWeek,
+            "end" => $firstWeek,
+            "length" => 0,
+        ],
+        "currentStreak" => [
+            "start" => $firstWeek,
+            "end" => $firstWeek,
+            "length" => 0,
+        ],
+    ];
+
+    // calculate contributions per week
+    $weeks = [];
+    foreach ($contributions as $date => $count) {
+        $week = getPreviousSunday($date);
+        if (!isset($weeks[$week])) {
+            $weeks[$week] = 0;
+        }
+        if ($count > 0) {
+            $weeks[$week] += $count;
+            // set first contribution date the first time
+            if (!$stats["firstContribution"]) {
+                $stats["firstContribution"] = $date;
+            }
+        }
+    }
+
+    // calculate the stats from the contributions array
+    foreach ($weeks as $week => $count) {
+        // add contribution count to total
+        $stats["totalContributions"] += $count;
+        // check if still in streak
+        if ($count > 0) {
+            // increment streak
+            ++$stats["currentStreak"]["length"];
+            $stats["currentStreak"]["end"] = $week;
+            // set start on first week of streak
+            if ($stats["currentStreak"]["length"] == 1) {
+                $stats["currentStreak"]["start"] = $week;
+            }
+            // update longestStreak
+            if ($stats["currentStreak"]["length"] > $stats["longestStreak"]["length"]) {
+                // copy current streak start, end, and length into longest streak
+                $stats["longestStreak"]["start"] = $stats["currentStreak"]["start"];
+                $stats["longestStreak"]["end"] = $stats["currentStreak"]["end"];
+                $stats["longestStreak"]["length"] = $stats["currentStreak"]["length"];
+            }
+        }
+        // reset streak but give exception for this week
+        elseif ($week != $thisWeek) {
+            // reset streak
+            $stats["currentStreak"]["length"] = 0;
+            $stats["currentStreak"]["start"] = $thisWeek;
+            $stats["currentStreak"]["end"] = $thisWeek;
+        }
+    }
+    return $stats;
+}
diff --git a/src/translations.php b/src/translations.php
index 11a4904..c800446 100644
--- a/src/translations.php
+++ b/src/translations.php
@@ -29,6 +29,8 @@ return [
         "Total Contributions" => "Total Contributions",
         "Current Streak" => "Current Streak",
         "Longest Streak" => "Longest Streak",
+        "Week Streak" => "Week Streak",
+        "Longest Week Streak" => "Longest Week Streak",
         "Present" => "Present",
     ],
     // Locales below are sorted alphabetically
@@ -67,6 +69,8 @@ return [
         "Total Contributions" => "סכום התרומות",
         "Current Streak" => "רצף נוכחי",
         "Longest Streak" => "רצף הכי ארוך",
+        "Week Streak" => "רצף שבועי",
+        "Longest Week Streak" => "רצף שבועי הכי ארוך",
         "Present" => "היום",
     ],
     "hi" => [
@@ -92,6 +96,8 @@ return [
         "Total Contributions" => "総ｺﾝﾄﾘﾋﾞｭｰｼｮﾝ数",
         "Current Streak" => "現在のストリーク",
         "Longest Streak" => "最長のストリーク",
+        "Week Streak" => "週間ストリーク",
+        "Longest Week Streak" => "最長の週間ストリーク",
         "Present" => "今",
     ],
     "kn" => [


Test patch:
diff --git a/tests/RenderTest.php b/tests/RenderTest.php
index 41ad735..c939981 100644
--- a/tests/RenderTest.php
+++ b/tests/RenderTest.php
@@ -23,6 +23,7 @@ final class RenderTest extends TestCase
     ];
 
     private $testStats = [
+        "mode" => "daily",
         "totalContributions" => 2048,
         "firstContribution" => "2016-08-10",
         "longestStreak" => [
@@ -108,17 +109,6 @@ final class RenderTest extends TestCase
         );
     }
 
-    /**
-     * Test JSON render
-     */
-    public function testJsonRender(): void
-    {
-        // Check json that is returned
-        $render = json_encode($this->testStats);
-        $expected = file_get_contents("tests/expected/test_stats.json");
-        $this->assertEquals($expected, $render);
-    }
-
     /**
      * Test split lines function
      */
diff --git a/tests/StatsTest.php b/tests/StatsTest.php
index bdc227e..c353464 100644
--- a/tests/StatsTest.php
+++ b/tests/StatsTest.php
@@ -104,6 +104,7 @@ final class StatsTest extends TestCase
         ];
         $stats = getContributionStats($contributions);
         $expected = [
+            "mode" => "daily",
             "totalContributions" => 17,
             "firstContribution" => "2021-04-15",
             "longestStreak" => [
@@ -133,6 +134,7 @@ final class StatsTest extends TestCase
         ];
         $stats = getContributionStats($contributions);
         $expected = [
+            "mode" => "daily",
             "totalContributions" => 10,
             "firstContribution" => "2021-04-15",
             "longestStreak" => [
@@ -162,6 +164,7 @@ final class StatsTest extends TestCase
         ];
         $stats = getContributionStats($contributions);
         $expected = [
+            "mode" => "daily",
             "totalContributions" => 8,
             "firstContribution" => "2021-04-15",
             "longestStreak" => [
@@ -189,6 +192,7 @@ final class StatsTest extends TestCase
         }
         $stats = getContributionStats($contributions);
         $expected = [
+            "mode" => "daily",
             "totalContributions" => 370,
             "firstContribution" => date("Y-m-d", strtotime("369 days ago")),
             "longestStreak" => [
@@ -252,6 +256,7 @@ final class StatsTest extends TestCase
         $contributions = getContributionDates($contributionGraphs);
         $stats = getContributionStats($contributions);
         $expected = [
+            "mode" => "daily",
             "totalContributions" => 3,
             "firstContribution" => date("Y-m-d", strtotime("yesterday")),
             "longestStreak" => [
@@ -267,4 +272,119 @@ final class StatsTest extends TestCase
         ];
         $this->assertEquals($expected, $stats);
     }
+
+    /**
+     * Test weekly stats
+     */
+    public function testWeeklyStats(): void
+    {
+        $contributions = [
+            "2022-11-12" => 5,
+            "2022-11-13" => 3, // Sunday
+            "2022-11-14" => 2,
+            "2022-11-15" => 0,
+            "2022-11-16" => 0,
+            "2022-11-17" => 0,
+            "2022-11-18" => 0,
+            "2022-11-19" => 0,
+            "2022-11-20" => 0, // Sunday
+            "2022-11-21" => 1,
+        ];
+        $stats = getWeeklyContributionStats($contributions);
+        $expected = [
+            "mode" => "weekly",
+            "totalContributions" => 11,
+            "firstContribution" => "2022-11-12",
+            "longestStreak" => [
+                "start" => "2022-11-06", // Previous Sunday before 2022-11-12
+                "end" => "2022-11-20",
+                "length" => 3,
+            ],
+            "currentStreak" => [
+                "start" => "2022-11-06",
+                "end" => "2022-11-20",
+                "length" => 3,
+            ],
+        ];
+        $this->assertEquals($expected, $stats);
+    }
+
+    /**
+     * Test weekly stats missing a week
+     */
+    public function testWeeklyStatsMissingWeek(): void
+    {
+        $contributions = [
+            "2022-11-05" => 2,
+            "2022-11-06" => 0, // Sunday
+            "2022-11-07" => 0,
+            "2022-11-08" => 0,
+            "2022-11-09" => 0,
+            "2022-11-10" => 0,
+            "2022-11-11" => 0,
+            "2022-11-12" => 5,
+            "2022-11-13" => 0, // Sunday
+            "2022-11-14" => 0,
+            "2022-11-15" => 0,
+            "2022-11-16" => 0,
+            "2022-11-17" => 0,
+            "2022-11-18" => 0,
+            "2022-11-19" => 0,
+            "2022-11-20" => 0, // Sunday
+            "2022-11-21" => 1,
+            "2022-11-22" => 1,
+        ];
+        $stats = getWeeklyContributionStats($contributions);
+        $expected = [
+            "mode" => "weekly",
+            "totalContributions" => 9,
+            "firstContribution" => "2022-11-05",
+            "longestStreak" => [
+                "start" => "2022-10-30", // Previous Sunday before 2022-11-05
+                "end" => "2022-11-06",
+                "length" => 2,
+            ],
+            "currentStreak" => [
+                "start" => "2022-11-20",
+                "end" => "2022-11-20",
+                "length" => 1,
+            ],
+        ];
+        $this->assertEquals($expected, $stats);
+    }
+
+    /**
+     * Test weekly stats missing this week
+     */
+    public function testWeeklyStatsMissingThisWeek(): void
+    {
+        $contributions = [];
+        $thisWeek = getPreviousSunday(date("Y-m-d"));
+        $lastWeek = getPreviousSunday(date("Y-m-d", strtotime("$thisWeek -1 week")));
+        for ($i = 0; $i < 7; $i++) {
+            $date = date("Y-m-d", strtotime("$lastWeek +$i days"));
+            $contributions[$date] = 1;
+        }
+        for ($i = 0; $i < 7; $i++) {
+            $date = date("Y-m-d", strtotime("$thisWeek +$i days"));
+            $contributions[$date] = 0;
+        }
+        $stats = getWeeklyContributionStats($contributions);
+        $expected = [
+            "mode" => "weekly",
+            "totalContributions" => 7,
+            "firstContribution" => $lastWeek,
+            "longestStreak" => [
+                "start" => $lastWeek,
+                "end" => $lastWeek,
+                "length" => 1,
+            ],
+            "currentStreak" => [
+                "start" => $lastWeek,
+                "end" => $lastWeek,
+                "length" => 1,
+            ],
+        ];
+        $this->assertEquals($expected, $stats);
+    }
 }
diff --git a/tests/TranslationsTest.php b/tests/TranslationsTest.php
index 8f9e3b8..7bac333 100644
--- a/tests/TranslationsTest.php
+++ b/tests/TranslationsTest.php
@@ -16,7 +16,16 @@ final class TranslationsTest extends TestCase
     {
         $translations = include "src/translations.php";
         $locales = array_keys($translations);
-        $valid_phrases = ["rtl", "date_format", "Total Contributions", "Current Streak", "Longest Streak", "Present"];
+        $valid_phrases = [
+            "rtl",
+            "date_format",
+            "Total Contributions",
+            "Current Streak",
+            "Longest Streak",
+            "Week Streak",
+            "Longest Week Streak",
+            "Present",
+        ];
         foreach ($locales as $locale) {
             $phrases = array_keys($translations[$locale]);
             $this->assertEmpty(array_diff($phrases, $valid_phrases), "Locale $locale contains invalid phrases");
diff --git a/tests/expected/test_stats.json b/tests/expected/test_stats.json
deleted file mode 100644
index 6104b87..0000000
--- a/tests/expected/test_stats.json
+++ /dev/null
@@ -1,1 +0,0 @@
-{"totalContributions":2048,"firstContribution":"2016-08-10","longestStreak":{"start":"2016-12-19","end":"2016-03-14","length":86},"currentStreak":{"start":"2019-03-28","end":"2019-04-12","length":16}}
\ No newline at end of file