# Week Segmentation Bug Investigation

**Date**: 2026-02-25
**Investigator**: Claude Code (Research Agent)
**Status**: Complete — multiple confirmed bugs found

---

## Executive Summary

A systematic investigation of the week segmentation logic across the gitflow-analytics codebase
found **7 distinct bugs** ranging from critical (data loss / wrong date ranges) to moderate
(missed commits at boundaries) to minor (internal inconsistency between modules). The most
severe issues are in `cache.py` (mixed timezone-aware/naive comparisons that corrupt cache TTL
checks) and `weekly_trends_writer.py` (floating 7-day windows used instead of
Monday-anchored calendar weeks, causing commits to land in wrong buckets across week boundaries).

---

## Bug Inventory

### BUG 1 — CRITICAL: `cache.py` uses `datetime.utcnow()` (naive) against timezone-aware DB column

**Files affected**:
- `src/gitflow_analytics/core/cache.py` — lines 171, 246, 356, 413, 452, 513, 742, 920, 1412, 1531, 1594, 1632, 1660

**Description**:
The database model `CachedCommit.cached_at` is declared as `DateTime(timezone=True)` with a
default of `utcnow_tz_aware()` (timezone-aware). But multiple methods in `GitAnalysisCache` call
`datetime.utcnow()` (returns a naive datetime) and compare it directly against this column:

```python
# cache.py line 452 — clear_stale_cache
cutoff_time = datetime.utcnow() - timedelta(hours=self.ttl_hours)  # NAIVE
session.query(CachedCommit).filter(CachedCommit.cached_at < cutoff_time).delete()
# cached_at is timezone-aware — comparison behaviour is DB-engine-dependent

# cache.py line 920 — _is_stale
return cached_at < datetime.utcnow() - timedelta(hours=self.ttl_hours)
# cached_at from DB is timezone-aware; datetime.utcnow() is naive

# cache.py line 1412 — get_repository_analysis_status
CachedCommit.cached_at >= datetime.utcnow() - timedelta(hours=self.ttl_hours)

# Also lines 171, 246, 356, 413, 513, 742, 1531, 1594, 1632, 1660 — similar pattern
```

**Impact on week segmentation**:
When SQLite is the backend (which it is, `gitflow_cache.db`), naive vs aware comparisons silently
produce wrong results for TTL checks. The `get_repository_analysis_status` method uses this
comparison to decide whether cached commits are "fresh enough" to trust. If the comparison is
wrong, the status check says zero fresh commits exist even when they are there, forcing an
un-needed re-fetch — or worse, accepting stale data from a prior week's run.

**Fix**: Replace every `datetime.utcnow()` in `cache.py` with `datetime.now(timezone.utc)`.

```python
# BEFORE
cutoff_time = datetime.utcnow() - timedelta(hours=self.ttl_hours)
# AFTER
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=self.ttl_hours)
```

The helper `utcnow_tz_aware()` already exists in `database.py` for this exact purpose and should
be imported and used throughout `cache.py`.

---

### BUG 2 — CRITICAL: `RepositoryAnalysisStatus` columns use `DateTime` (no `timezone=True`)

**File**: `src/gitflow_analytics/models/database.py` — lines 437–438, 454

```python
# database.py lines 437-438
analysis_start = Column(DateTime, nullable=False)   # NO timezone=True
analysis_end   = Column(DateTime, nullable=False)   # NO timezone=True

# line 454
last_updated = Column(DateTime, default=datetime.utcnow, onupdate=utcnow_tz_aware)
#                                        ^^^^^^^^^^^^^^^^ naive!
```

**Impact**:
The cache-status lookup in `get_repository_analysis_status()` filters by exact equality:

```python
# cache.py lines 1386-1387
RepositoryAnalysisStatus.analysis_start == analysis_start,
RepositoryAnalysisStatus.analysis_end   == analysis_end,
```

`analysis_start` / `analysis_end` are passed in as timezone-aware UTC datetimes from the CLI.
When these are stored they lose their timezone offset (stored as naive in SQLite). On the next
run the values from the CLI are timezone-aware; the stored values are naive; the `==` comparison
fails — so the cache is **never hit**. This forces a full re-fetch every single run, which then
causes inconsistent week boundaries because `start_date` is re-calculated as
`datetime.now(timezone.utc)` at a slightly different moment.

**Fix**:
```python
# database.py
analysis_start = Column(DateTime(timezone=True), nullable=False)
analysis_end   = Column(DateTime(timezone=True), nullable=False)
last_updated   = Column(DateTime(timezone=True), default=utcnow_tz_aware, onupdate=utcnow_tz_aware)
```
A schema migration is also required (schema version bump from 2.0 → 2.1 or similar).

---

### BUG 3 — HIGH: `--weeks N` calculates different date ranges in fetch vs. batch-mode

**File**: `src/gitflow_analytics/cli.py`

Two different code paths use different formulas for the analysis start date:

**Fetch sub-command path (line 1096-1097)**:
```python
end_date   = datetime.now(timezone.utc)          # now — not week-aligned
start_date = end_date - timedelta(weeks=weeks)   # raw N-week rollback
```

**Analyze batch-mode path (lines 1358-1369)**:
```python
current_week_start      = get_week_start(current_time)
last_complete_week_start = current_week_start - timedelta(weeks=1)
start_date = last_complete_week_start - timedelta(weeks=weeks - 1)  # week-aligned
end_date   = get_week_end(last_complete_week_start + timedelta(days=6))
```

The batch-mode path correctly anchors to Monday/Sunday boundaries and excludes the current
(incomplete) week. The fetch-mode path starts from "now" and goes back exactly N×7 days —
not Monday-aligned. This means:

- Commits at the very end of a week boundary are split between the two paths
- If a user runs `fetch` then `analyze`, the date ranges do not match, causing commits to be
  missing or duplicated at the week boundaries
- On a Wednesday, `--weeks 4` in fetch mode covers a partial current week plus 3.4 complete
  weeks, while batch-mode covers exactly 4 complete Mon–Sun weeks ending last Sunday

**Fix**: Unify both code paths to use the week-aligned formula, ideally by extracting it into a
shared helper.

```python
def compute_analysis_period(weeks: int, reference_time: datetime = None) -> tuple[datetime, datetime]:
    """Return (start_date, end_date) for N complete Mon-Sun weeks, ending last Sunday."""
    now = reference_time or datetime.now(timezone.utc)
    current_week_start = get_week_start(now)
    last_complete_week_start = current_week_start - timedelta(weeks=1)
    start_date = last_complete_week_start - timedelta(weeks=weeks - 1)
    end_date = get_week_end(last_complete_week_start + timedelta(days=6))
    return start_date, end_date
```

---

### BUG 4 — HIGH: `weekly_trends_writer.py` uses floating 7-day windows instead of calendar weeks

**File**: `src/gitflow_analytics/reports/weekly_trends_writer.py` — lines 109–156, 239–281

The `WeeklyTrendsWriter` assigns commits to week buckets using a floating-window approach based
on the **most recent commit's date** rather than fixed Monday-anchored calendar weeks:

```python
# weekly_trends_writer.py lines 109-126
latest_date = sorted_commits[0]['timestamp']    # Most recent commit
if hasattr(latest_date, 'date'):
    latest_date = latest_date.date()

for commit in sorted_commits:
    commit_date = timestamp.date()
    days_diff = (latest_date - commit_date).days
    week_num = days_diff // 7                   # 7-day floating window!
```

And the reported `week_start` is also computed relative to `latest_date`:
```python
# line 155
week_start = latest_date - timedelta(days=(week_num * 7))
```

**Impact**:
- If the most recent commit is on a Thursday, "week 0" runs Thursday to Thursday — not Mon–Sun
- A commit made on Monday of one calendar week may land in a different "bucket" than a commit
  made on Sunday of that same calendar week
- The `week_start` date in the CSV output will be a random weekday (whatever day the latest
  commit fell on), not a Monday
- This causes commits to be distributed differently in the CSV reports than in the narrative
  report (which uses `_get_week_start()` / Monday-anchored logic)
- Running the analysis on different days will produce different per-week distributions for the
  same historical data

**Fix**: Replace the floating-window calculation with Monday-anchored calendar weeks:

```python
from datetime import datetime, timedelta, timezone

def _get_monday(d) -> date:
    """Return the Monday of the week containing date d."""
    if hasattr(d, 'date'):
        d = d.date()
    return d - timedelta(days=d.weekday())   # weekday() 0=Mon

for commit in sorted_commits:
    timestamp = commit.get('timestamp')
    commit_date = timestamp.date() if hasattr(timestamp, 'date') else timestamp
    week_monday = _get_monday(commit_date)   # calendar week anchor
    developer_weeks[developer][week_monday][classification] += 1
```

Then the `week_start` in the row is simply `week_monday`, which is always a Monday.

---

### BUG 5 — HIGH: `cache_warming` uses naive datetimes and string date for git

**File**: `src/gitflow_analytics/core/cache.py` — lines 799–828

```python
def warm_cache(self, repo_paths, weeks=12):
    from datetime import datetime, timedelta    # shadows module-level import
    ...
    start_time  = datetime.now()               # NAIVE — no timezone
    cutoff_date = datetime.now() - timedelta(weeks=weeks)  # NAIVE

    commits = list(
        repo.iter_commits(all=True, since=cutoff_date.strftime("%Y-%m-%d"))
        #                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^
        # "YYYY-MM-DD" with no time component means git treats it as
        # start-of-day in the LOCAL timezone of the machine running the code
    )
```

**Impact**:
- `start_time` and `cutoff_date` are naive; subsequent arithmetic against timezone-aware
  datetime objects (e.g., for duration calculation) will throw a `TypeError`
- The `strftime("%Y-%m-%d")` passed to `git log --since` is interpreted in the server's local
  timezone. If the server is UTC+something, an extra day of commits can appear; if UTC-something,
  a day of commits is lost. This shifts the week boundaries by up to 24 hours.
- The local `from datetime import datetime, timedelta` re-import shadows the module-level import
  and discards `timezone`, making it impossible to add timezone awareness without restructuring.

**Fix**:
```python
def warm_cache(self, repo_paths, weeks=12):
    # Remove local re-import — use module-level datetime/timedelta/timezone
    start_time  = datetime.now(timezone.utc)
    cutoff_date = datetime.now(timezone.utc) - timedelta(weeks=weeks)
    # Pass full ISO datetime so git uses UTC
    commits = list(
        repo.iter_commits(all=True, since=cutoff_date.isoformat())
    )
```

---

### BUG 6 — MODERATE: `_analyze_commit_minimal()` stores raw (potentially naive) timestamp

**File**: `src/gitflow_analytics/core/cache.py` — line 897

```python
def _analyze_commit_minimal(self, repo, commit, repo_path):
    commit_data = {
        ...
        "timestamp": commit.committed_datetime,  # may be naive or non-UTC aware
        ...
    }
```

The main analysis path (`_analyze_commit` in `analyzer.py` lines 868–883) correctly normalises
the timestamp to UTC-aware:

```python
commit_timestamp = commit.committed_datetime
if commit_timestamp.tzinfo is None:
    commit_timestamp = commit_timestamp.replace(tzinfo=timezone.utc)
elif commit_timestamp.tzinfo != timezone.utc:
    commit_timestamp = commit_timestamp.astimezone(timezone.utc)
```

But the cache-warming minimal path skips this normalisation. Commits ingested through
`warm_cache` → `_analyze_commit_minimal` will have inconsistent timezone data in the
`CachedCommit.timestamp` column, causing off-by-offset errors in date range filtering (e.g., a
commit made at 11 PM UTC stored with local timezone offset will appear to fall in the
next calendar day when compared against UTC boundaries).

**Fix**: Add the same normalisation block to `_analyze_commit_minimal`:

```python
commit_timestamp = commit.committed_datetime
if commit_timestamp.tzinfo is None:
    commit_timestamp = commit_timestamp.replace(tzinfo=timezone.utc)
elif commit_timestamp.tzinfo != timezone.utc:
    commit_timestamp = commit_timestamp.astimezone(timezone.utc)
commit_data = {
    ...
    "timestamp": commit_timestamp,
    ...
}
```

---

### BUG 7 — MODERATE: `narrative_writer._calculate_weekly_classification_percentages` drops last partial week

**File**: `src/gitflow_analytics/reports/narrative_writer.py` — lines 939–945

```python
current_week_start = self._get_week_start(analysis_start)

while current_week_start <= analysis_end:
    week_end = current_week_start + timedelta(days=6, hours=23, minutes=59, seconds=59)
    # Only include this week if it ends before or on the analysis end date
    if week_end <= analysis_end:      # <— strict inequality
        analysis_weeks.append(current_week_start)
    current_week_start += timedelta(weeks=1)
```

The CLI calculates `end_date = get_week_end(last_complete_week_start + timedelta(days=6))`,
which resolves to Sunday 23:59:59.999999 UTC. The narrative writer's week boundary is
`week_start + 6 days + 23:59:59` (no microseconds). Because `23:59:59` < `23:59:59.999999`,
the condition `week_end <= analysis_end` is True only when the microseconds are 0. In practice
this usually works, but there is a latent inconsistency:

- If `end_date` is exactly `Sunday 23:59:59` (no microseconds), the week is included.
- If `end_date` is `Sunday 23:59:59.999999` (from `get_week_end`), the week is included.
- But if due to Bug 3, `end_date = datetime.now()` (not week-aligned), the last week may not
  have `week_end <= analysis_end` satisfied, silently dropping up to 6 days of commits.

Also, when `analysis_start_date` and `analysis_end_date` are not passed (the else-branch at
lines 912–931), the analysis window is computed from actual commit timestamps. If no commits
exist in a particular week, that week is entirely missing from the report — users see gaps.

**Fix**:
1. Use `microsecond=999999` in the week-end boundary computation to match `get_week_end()`.
2. Always pass `analysis_start_date` and `analysis_end_date` to
   `_calculate_weekly_classification_percentages` from all call sites (verify call sites at
   lines 1553–1559, 1578–1584, 1603–1609, 1627–1633, 1820–1826 all pass these arguments).

---

## Summary Table

| # | Severity | File | Lines | Root Cause | Effect |
|---|----------|------|-------|-----------|--------|
| 1 | CRITICAL | `core/cache.py` | 171,246,356,413,452,513,742,920,1412,1531,1594,1632,1660 | `datetime.utcnow()` (naive) vs timezone-aware DB column | Cache TTL checks silently wrong; stale/wrong data used |
| 2 | CRITICAL | `models/database.py` | 437,438,454 | `DateTime` without `timezone=True` on status table | Cache never hits; re-fetch every run; week boundaries shift |
| 3 | HIGH | `cli.py` | 1096–1097 vs 1362–1369 | Two different start-date formulas | Fetch and analyze use different date ranges; boundary commits missing |
| 4 | HIGH | `reports/weekly_trends_writer.py` | 109–156, 239–281 | Floating 7-day windows instead of Mon-anchored calendar weeks | Commits in wrong week buckets; non-deterministic per-day distribution |
| 5 | HIGH | `core/cache.py` | 799–828 | `warm_cache` uses naive datetimes + string date for git | Off-by-24h on git query; potential TypeError in duration calc |
| 6 | MODERATE | `core/cache.py` | 897 | `_analyze_commit_minimal` skips timestamp normalisation | Non-UTC timestamps stored; off-by-offset errors in date filtering |
| 7 | MODERATE | `reports/narrative_writer.py` | 939–945 | Strict `<=` drops week when end_date has microseconds | Last week silently omitted from trend lines |

---

## Recommended Fix Order

1. **Bug 2** — Fix `RepositoryAnalysisStatus` column types (no migration needed for SQLite if
   you just wipe the cache; add schema version bump). This unblocks cache hits.
2. **Bug 1** — Replace all `datetime.utcnow()` in `cache.py` with `datetime.now(timezone.utc)`.
3. **Bug 3** — Extract `compute_analysis_period()` helper and use it in both code paths.
4. **Bug 4** — Fix `WeeklyTrendsWriter` to use Monday-anchored calendar weeks.
5. **Bug 5** — Fix `warm_cache` naive datetimes.
6. **Bug 6** — Add timestamp normalisation to `_analyze_commit_minimal`.
7. **Bug 7** — Fix microsecond boundary in narrative writer week loop.

---

## Testing Gaps

No existing tests specifically cover:
- Week boundary behaviour (commits on Monday 00:00 vs Sunday 23:59)
- Timezone-naive vs timezone-aware commit timestamps
- The floating-window vs calendar-week split in `WeeklyTrendsWriter`
- Consistency between fetch-mode and batch-mode date ranges
- Cache-hit behaviour when `analysis_start`/`analysis_end` are timezone-aware

Recommended new test cases:
```
test_get_week_start_monday_boundary()       # commit at Sunday 23:59, Monday 00:00
test_weekly_trends_calendar_week_anchor()   # commits spanning Mon-Sun vs Tue-Mon window
test_fetch_batch_date_range_consistency()   # same N for both paths
test_cache_ttl_timezone_aware()             # datetime.now(tz) vs naive comparison
test_analysis_status_cache_hit()            # timezone-aware equality in DB query
```
