feat(tickets): getMyClosedTicketsForRange for period close-lists#3647
Conversation
Mobile Progress needs "Closed this week / this month" — completed arcs keyed on close-date within a period, independent of how recently the project was otherwise touched, so finished work never disappears from the reflection. The data was already one layer down: getMyClosedTicketsForDate builds on getStatusChangeEvents(ids, from, to), which already takes a date range — the service just pinned it to a single day (from == to). - Add getMyClosedTicketsForRange(userId, from, to) (@api, VIEW): same "closed" definition (currently DONE and its status changed to that DONE status within [from, to]; one row per ticket keyed to its latest completion), just over a range. Tolerates a reversed range; defaults either bound to today. - getMyClosedTicketsForDate now delegates to it with from == to — behaviour is byte-identical (getStatusChangeEvents already spans date 00:00:00..23:59:59), just DRY. One query per call, no N+1 — the mobile alternative was 7–30 per-day calls. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
There was a problem hiding this comment.
Pull request overview
Adds a date-range variant of the “my closed tickets” query to support mobile Progress views (“Closed this week / this month”), while refactoring the existing single-day method to delegate to the new range method for DRYness.
Changes:
- Added
getMyClosedTicketsForRange(?int $userId, ?string $from, ?string $to)to return currently-DONE tickets whose DONE status was set within an inclusive[from, to]date range, withdateClosedpopulated from the newest matching event per ticket. - Updated
getMyClosedTicketsForDate()to delegate to the range method withfrom == to(intended to keep behavior identical). - Updated docblocks/comments to describe the range behavior and mobile use case.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Same "closed" definition as the single-date form: the ticket is currently | ||
| * DONE and its status was changed to that DONE status within the range. A | ||
| * ticket completed more than once in the range is included once, keyed to | ||
| * its latest completion (events are newest-first). Defaults to today when | ||
| * either bound is omitted. |
| public function getMyClosedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array | ||
| { | ||
| $from = $from ?: date('Y-m-d'); | ||
| $to = $to ?: date('Y-m-d'); | ||
| // Tolerate a reversed range rather than returning nothing. | ||
| if ($from > $to) { | ||
| [$from, $to] = [$to, $from]; | ||
| } |
|
Maintainer review (advisory — merge authority stays with @marcelfolaron / @broskees) · diff read in full at head 1. Intent: Adds 2. Change requests (the refactor reads clean and low-risk — these are confirm-before-merge):
3. Acceptance checklist (must pass before merge):
4. CI status: Merge-order note: #3649 ( Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
Copilot review: the 'defaults to today' wording didn't reflect that an omitted bound combined with a reversed range gets swapped. Document the actual behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
| $from = $from ?: date('Y-m-d'); | ||
| $to = $to ?: date('Y-m-d'); |
| #[RequiresPermission(TicketsPermissions::VIEW)] | ||
| public function getMyClosedTicketsForRange(?int $userId = null, ?string $from = null, ?string $to = null): array | ||
| { | ||
| $from = $from ?: date('Y-m-d'); | ||
| $to = $to ?: date('Y-m-d'); |
… dedup Copilot review (#3647): add unit coverage — reversed range is normalized earliest-first, only changes into the current DONE status count, and a multiply-completed ticket keeps its latest completion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
| $ticketRepository = $this->make(TicketRepository::class, [ | ||
| 'simpleTicketQuery' => fn () => [ | ||
| ['id' => 10, 'type' => 'task', 'projectId' => 5, 'status' => 0], | ||
| ['id' => 20, 'type' => 'task', 'projectId' => 5, 'status' => 0], | ||
| ], | ||
| 'getStateLabels' => fn () => [ | ||
| 0 => ['statusType' => 'DONE', 'name' => 'Done', 'class' => ''], | ||
| 3 => ['statusType' => 'INPROGRESS', 'name' => 'In Progress', 'class' => ''], | ||
| ], |
|
Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head 1. Intent: Adds What changed since my last review ✅ — the missing-test blocker is resolved. The new commit ships 2. Change requests (all non-blocking — confirm-before-merge):
3. Acceptance checklist (must pass before merge):
4. CI status: Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
Copilot re-review (#3647): - IDOR: force a non-admin's userId to the session user (only admins may read another user's closures). - Resolve today's date once and reuse for both bound defaults, so a run across midnight can't make them disagree. - Test stubs take variadic args. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
| * | ||
| * Thin wrapper over {@see getMyClosedTicketsForRange} with from == to. | ||
| */ |
| public function test_closed_tickets_range_normalizes_swapped_range_and_keeps_latest_completion(): void | ||
| { | ||
| session(['userdata' => ['id' => 1]]); | ||
| $capturedFrom = null; | ||
| $capturedTo = null; |
|
Maintainer re-review (advisory — merge authority stays with @marcelfolaron / @broskees) · re-reviewed at head 1. Intent: Adds What changed since my last review: the code at head is stable relative to 2. Change requests (all non-blocking — confirm-before-merge; carried forward, still open):
3. Acceptance checklist (must pass before merge):
4. CI status: Merge-order: #3649 inserts a method immediately after this one in Advisory review only — not an approval, and I am not marking this ready or merging. Merge authority stays with @marcelfolaron / @broskees. |
review) Copilot flagged that the non-admin IDOR branch (caller supplies another user's id -> forced to the session user) was untested. Add a test asserting the repository is queried with the session user, not the caller-supplied id. TicketsServiceTest green (24 tests / 53 assertions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
| // Resolve "today" once so a run across midnight can't disagree on bounds. | ||
| $today = date('Y-m-d'); | ||
| $from = $from ?: $today; | ||
| $to = $to ?: $today; | ||
| // Tolerate a reversed range rather than returning nothing. | ||
| if ($from > $to) { | ||
| [$from, $to] = [$to, $from]; | ||
| } |
| public function test_closed_tickets_range_forces_session_user_for_non_admin(): void | ||
| { | ||
| // Non-admin session user (no admin role granted). | ||
| session(['userdata' => ['id' => 1]]); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
app/Domain/Tickets/Services/Tickets.php:2475
getStatusChangeEvents()expectsfromDate/toDateasY-m-dand concatenates times ($fromDate.' 00:00:00'). SincegetMyClosedTicketsForRange()is@apiand accepts free-form strings, callers passing a full datetime (e.g.2026-07-01 12:00:00) will produce an invalid bound (2026-07-01 12:00:00 00:00:00) and can break the query. Normalize the inputs toY-m-dbefore comparing/swapping and calling the repository.
// Resolve "today" once so a run across midnight can't disagree on bounds.
$today = date('Y-m-d');
$from = $from ?: $today;
$to = $to ?: $today;
// Tolerate a reversed range rather than returning nothing.
if ($from > $to) {
[$from, $to] = [$to, $from];
}
Resolve the TicketsServiceTest conflict by keeping BOTH suites — master's closed-range tests (#3647/#3650) and this branch's commented-range tests. Tickets service/repo auto-merged cleanly. Full suite green (30 tests / 62 assertions), Pint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NtfhjPwUEEHJb4Jf7714eR
Why
Mobile Progress (the reflection surface) needs "Closed this week / this month" — completed arcs keyed on close-date within a period, independent of how recently the project was otherwise touched, so finished work never disappears from the reflection. The existing
getMyClosedTicketsForDateonly answers a single day.What
The range was already supported one layer down:
getMyClosedTicketsForDatebuilds ongetStatusChangeEvents(ids, $from, $to), which already takes a date range (whereBetween dateModified [from 00:00:00, to 23:59:59]) — the service just pinned it to one day (from == to).getMyClosedTicketsForRange(?int $userId, ?string $from, ?string $to)(@api,RequiresPermission VIEW): same "closed" definition as the single-date form — the ticket is currently DONE and its status changed to that DONE status within[from, to]; one row per ticket keyed to its latest completion (events are newest-first). Tolerates a reversed range; defaults either bound to today.getMyClosedTicketsForDatenow delegates to it withfrom == to. Behaviour is byte-identical (the underlying query already spansdate 00:00:00 .. 23:59:59), just DRY.One query per call, no N+1 — the mobile alternative was 7–30 per-day calls for a week/month.
Risk
Low. Additive method + a pure refactor of the existing one into a delegate; no query or output shape changes for
getMyClosedTicketsForDate. Pint +php -lclean.🤖 Generated with Claude Code