A client opened two filter regression tickets on a Document Overview plugin I had just delivered. Both looked, on the surface, like my code was wrong.
A week later the work had ended somewhere I did not expect: two pull requests I opened against the Shopware 6 core, both tagged for milestone 6.7.10.0. One has an approving review from a community contributor and is waiting for maintainer review. The other is sitting in the triage queue, awaiting its first review. Both fixes land in the core sw-date-filter component itself, where every Shopware 6 admin will pick them up.
This is a story about overriding admin components, the bugs you inherit when you do, and the dual approach of shipping a local fix while contributing the root cause upstream. The client got their fix in the next plugin build. Everyone else on Shopware 6 will get it in 6.7.10.0.
The Plugin That Surfaced the Bug
The plugin in question is a Document Overview module I built for a German Shopware merchant. It adds an admin route that lists generated documents (invoices, delivery notes, credit notes) across all orders, with role-based read permissions and a custom column layout. The merchantâs accounting team uses it daily to reconcile open invoices for the current quarter, this monthâs deliveries, and other time-bounded reporting windows.
Date filtering is not a nice-to-have for this use case. It is the primary way the team narrows a list of tens of thousands of documents down to the rows they actually need to act on. The âLast Quarterâ timeframe alone runs hundreds of times a month.
The component handling that filtering is sw-date-filter. My plugin extends it via a component override to add the permission-aware column rendering, but the filter logic itself is inherited unchanged. That detail is the whole reason this story exists.
What the Client Actually Reported
Two tickets came in within the same week.
The first: âLast Quarter is showing the entire previous year plus this year so far.â A screenshot was attached, showing a from date of October 1 of the previous year and a to date of December 31 of the current year. Roughly fifteen months instead of three.
The second, two days later: âWhen I filter by âtodayâ the list shows yesterdayâs documents and skips one I created this morning.â This one came with a profile screenshot showing the userâs timezone set to a non-UTC zone.
Initial reaction on both: tested locally, could not reproduce.
The first reproduced eventually, but only after I forced my dev system clock to a Q1 date. Until that moment, every test I had run on âLast Quarterâ had been from April, a quarter where the bug is invisible. That is a property worth re-reading. The bug was real, and my live testing had simply fallen outside the months where it triggered.
The second took longer. My dev environment, my testerâs environment, and the staging instance were all on UTC profiles. The clientâs accounting team was not. Once I switched my profile timezone to a US zone and reloaded the listing, the off-by-one-day behavior appeared on the first try.
Two reproducibility problems with the same shape: the bug is environmental, and the environment most developers test in happens to mask it.
Tracing Past My Override into sw-date-filter
My override of sw-date-filter is small. It adds a permission check before rendering the filter at all, and it customises the way the active filter chip is labeled in the toolbar. The core methods, the timeframe computation, the emit logic, the date input handling, none of those are touched.
That is the design intent of admin component overrides. You layer on top. You inherit the parentâs computed properties and methods unless you explicitly replace them. It is the right pattern for almost every extension I write. It is also why the bugs were now sitting in my listing.
For the âLast Quarterâ issue, I traced the call from the dropdown selection through onTimeframeSelect, into getPreviousQuarterDates, and stopped on a one-line discrepancy in how the end date was constructed. For the timezone issue, I traced the value flow from the date input through updateFilter, watched it pass through a pair of setHours() calls, and saw the resulting ISO strings being attached to the criteria object as UTC bounds.
Neither path crossed any code I had written. Both bugs were in the parent component. My options narrowed to two: monkey-patch the parent inside my override and call it a fix, or trace the bugs to their roots in core and submit upstream PRs.
I did both. The local patch shipped to the client first; the PRs followed.
PR #16380: The Last-Quarter Year Bug That Hides Nine Months a Year
The âLast Quarterâ timeframe is computed by getPreviousQuarterDates, which derives the start and end of the previous calendar quarter relative to today. The function looks correct at a glance. It builds the start date from date.getFullYear(), the current quarter index, and a month offset. It builds the end date the same way.
That last sentence is the problem.
// Before â endDate uses today's year, which jumps forward in Q1
const endDate = new Date(date.getFullYear(), startDate.getMonth() + 3, 0, 23, 59, 59);
// After â endDate stays anchored to the quarter's start year
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 3, 0, 23, 59, 59);
The fix is one identifier. From April through December, date.getFullYear() and startDate.getFullYear() return the same value, so the end date lands correctly in the same year as the start date. In January, February, and March, the previous quarter started in the previous year (October through December), but date.getFullYear() returns the current year. The end date is then constructed in the current year, three months past the previous quarterâs start month, which puts it at the end of December of the current year. The result is a fifteen-month range that spans two calendar years.
The bug is invisible nine months a year. That is not a metaphor. It is the literal property of the bug: from April 1 through December 31, the math accidentally agrees with itself.
The regression test pins the system clock to a Q1 date and asserts the emitted bounds are in the previous year:
describe('lastQuarter boundary when today is in Q1', () => {
beforeEach(() => {
jest.setSystemTime(new Date(1337, 1, 15));
});
it('should compute last quarter as Oct-Dec of the previous year', async () => {
// ...
wrapper.vm.onTimeframeSelect('lastQuarter');
expect(wrapper.emitted()['filter-update']).toEqual([
[
'releaseDate',
[{
field: 'releaseDate',
parameters: {
gte: '1336-10-01T00:00:00.000Z',
lte: '1336-12-31T23:59:59.000Z',
},
type: 'range',
}],
{
from: '1336-10-01T00:00:00.000Z',
timeframe: 'lastQuarter',
to: '1336-12-31T23:59:59.000Z',
},
],
]);
});
});
The system time is pinned to February 15, year 1337, deliberately. Without the pin, the test would pass nine times out of twelve, depending on the month it ran in. With the pin, the assertion is reliable, and the test fails loudly without the fix.
I opened the PR on April 22; it was tagged for milestone 6.7.10.0. A community contributor reviewed it the next morning with an approving comment. Maintainer review is still pending. PR #16380 on GitHub.
PR #16439: When Shopwareâs Date Filter Ignores the User Timezone
The timezone bug took longer to characterise because there were actually two defects on the same line of code, and I only noticed the second one while writing the test for the first.
Here is the original block:
if (this.dateValue.from) {
const from = new Date(this.dateValue.from);
from.setHours(0, 0, 0);
this.dateValue.from = from.toISOString();
}
if (this.dateValue.to) {
const to = new Date(this.dateValue.to);
to.setHours(23, 59, 59);
this.dateValue.to = to.toISOString();
}
this.$emit('filter-update', this.filter.name, params, this.dateValue);
Date.setHours(0, 0, 0) mutates a JavaScript Date objectâs local hour, minute, and second components. The Date object is then serialised back to ISO via toISOString(), which is UTC. The âlocalâ in setHours is the runtimeâs local time, which on a server-rendered admin page means UTC by the time it reaches the browser via SSR-equivalent paths, and on a freshly loaded admin page means the host browserâs local timezone, which the user can change without changing their Shopware profile timezone.
Either way, the day boundary derived here has nothing to do with the userâs profile timeZone. List date columns, on the other hand, format display values using the profile timezone. So a user in America/Los_Angeles filtering for January 22 would query 2021-01-22T00:00:00Z to 2021-01-22T23:59:59Z (UTC), while the date column shows each rowâs date in LA. Rows from 2021-01-21T17:00:00Z (which display as January 21 in LA) get incorrectly included. Rows from 2021-01-22T20:00:00Z (which display as January 22 in LA) get filtered out.
The second defect: this.dateValue.from = from.toISOString() mutates the componentâs reactive dateValue. The parent sw-range-filter deep-watches value, so this mutation re-fires the watcher and produces a duplicate filter-update event for every single field change. The original test suite even acknowledged this with a comment: â[1] is a duplicate emission from sw-date-filter mutating dateValue.from to ISO (triggers sw-range-filter watch again)â. The duplicate was treated as expected behavior, asserted on, and shipped.
The fix needed to address both defects in the same pass.
/**
* mt-datepicker in date-only mode may emit an ISO instant whose wall-clock
* time is not midnight (e.g. it carries over the current time on the
* picked day). Snap to the start of the picked day in the given timezone
* and derive the end of that same day so filter bounds cover the full
* intended calendar day as the list's date formatter displays it.
*/
dayBoundsInTz(iso, tz) {
const instant = new Date(iso);
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).formatToParts(instant);
const read = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
let hour = read('hour');
if (hour === 24) {
hour = 0;
}
const minute = read('minute');
const second = read('second');
const ms = instant.getUTCMilliseconds();
const offsetIntoDayMs = ((hour * 60 + minute) * 60 + second) * 1000 + ms;
const startMs = instant.getTime() - offsetIntoDayMs;
const oneDayMs = 24 * 60 * 60 * 1000;
return {
gte: new Date(startMs).toISOString(),
lte: new Date(startMs + oneDayMs - 1).toISOString(),
};
},
Intl.DateTimeFormat with an explicit timeZone reads the wall-clock components of the picked instant in that timezone. From those components the helper computes how far into the day the instant lies, subtracts that offset to get the start of the userâs calendar day, and adds 24 hours minus 1 millisecond to get the end. The timezone is pulled from Shopware.Store.get('session').currentUser.timeZone, with a UTC fallback for the unauthenticated edge case.
The duplicate-emission fix is more subtle. updateFilter no longer mutates this.dateValue. Instead it builds a derived object containing the new from and to and emits that as the third argument. The consumer-visible value is identical to before, but the watcher in sw-range-filter no longer fires a second time. One filter-update per field change, not two.
The new test sets a session user with timezone America/Los_Angeles, picks two non-midnight UTC instants that both land on the same LA day, and asserts the emitted bounds collapse to LAâs day boundaries:
const fromInput = wrapper.find('.sw-date-filter__from').find('input');
await fromInput.setValue('2021-01-22T15:30:00.000Z');
await fromInput.trigger('input');
await flushPromises();
const toInput = wrapper.find('.sw-date-filter__to').find('input');
await toInput.setValue('2021-01-23T03:00:00.000Z');
await toInput.trigger('input');
await flushPromises();
const lastEmit = wrapper.emitted()['filter-update'].at(-1);
expect(lastEmit).toEqual([
'releaseDate',
[Criteria.range('releaseDate', {
gte: '2021-01-22T08:00:00.000Z',
lte: '2021-01-23T07:59:59.999Z',
})],
{
from: '2021-01-22T08:00:00.000Z',
to: '2021-01-23T07:59:59.999Z',
timeframe: 'custom',
},
]);
Both UTC instants land on January 22 in LA. The emitted bounds are January 22 LA midnight to January 23 LA midnight minus one millisecond, expressed in UTC: 08:00:00.000Z to 07:59:59.999Z of the following UTC day. That is exactly the calendar day the user picked, in the timezone the user works in.
I opened the PR on April 24; it was tagged for milestone 6.7.10.0. As of writing, it has zero reviews and the needs-triage label. PR #16439 on GitHub.
Shipping the Fix Locally Without Waiting for 6.7.10.0
A merchant whose accounting team is reconciling open invoices today does not want to hear about milestone tags. They want the filter to work this week. The PRs may take days, weeks, or longer to land, depending on review queue and milestone freeze.
The local fix lives in the pluginâs existing sw-date-filter override. The override already extends the parent component, so it can replace getPreviousQuarterDates and updateFilter with the corrected implementations:
import template from './sw-date-filter.html.twig';
const { Component, Criteria, Store } = Shopware;
Component.override('sw-date-filter', {
template,
methods: {
getPreviousQuarterDates(date) {
const quarter = Math.floor(date.getMonth() / 3);
const startDate = new Date(date.getFullYear(), quarter * 3 - 3, 1, 0, 0, 0);
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 3, 0, 23, 59, 59);
return { startDate, endDate };
},
// updateFilter and dayBoundsInTz mirroring the upstream PR
// omitted for brevity, same shape as PR #16439
},
});
The override re-defines the buggy methods with the corrected versions from the two PRs. The next time the merchant deploys the plugin, the date filter behaves correctly for them, regardless of when 6.7.10.0 ships.
This is the dual approach worth being explicit about. The override patches the immediate symptom for one client. The PRs fix the root cause for everyone on Shopware 6, and they let me delete the override on this clientâs project once 6.7.10.0 lands. The local fix is temporary by design. The upstream fix is permanent.
What I Took Away
A few things worth writing down for the next time.
Overrides inherit bugs. That is the design strength of admin component extension and the reason it is the right default for almost every plugin. It is also a trap when the parent is wrong. If you are debugging a behavior in your override and the relevant code is not in your override, the bug is not in your override. Trace into the parent. I covered the broader pattern in Rethinking Shopware 6 Plugin Development.
âWorks on my machineâ is a hypothesis, not a verdict. Both bugs in this story were real and reproducible. They needed a Q1 date and a non-UTC profile, respectively. My dev environment had neither at the moment I tried to reproduce. The diagnostic question is not âdoes it work for meâ but âwhat is different between my environment and theirs.â
Upstream the root cause when you can. The local patch closes the ticket. The upstream PR closes the bug. Both should happen. The PR also does the work for every other Shopware 6 admin user who has not yet noticed the issue, including the next agency that hits it from a Q1 deadline.
The release-cadence answer for impatient clients is âboth.â Patch locally, contribute upstream, plan to delete the local patch when the release ships. The client never has to wait, and the codebase tends toward simpler, not more, custom code over time.
Where This Leaves Things
PR #16380 is open with one approving review from a community contributor, awaiting maintainer review for milestone 6.7.10.0. PR #16439 is open in the triage queue, also tagged for 6.7.10.0. The Document Overview pluginâs date filter has been working correctly for the client since the day after the second ticket landed.
If you are running a Shopware 6 shop and the kind of admin work in this post is the kind of work you want done properly, get in touch. More of the projects this work came from are in the case studies.
Both PRs are public on GitHub: #16380, #16439. My contributor profile is at github.com/zaifastafa.
Date Filters in Shopware 6: Frequently Asked Questions
What is sw-date-filter in Shopware 6? sw-date-filter is the Vue component used by Shopware 6 listing filter panels to apply from/to date ranges and quick timeframes like âLast Quarterâ or âLast Monthâ. It emits a filter-update event with a Criteria.range so the listing query is constrained to the selected date window. Most admin lists with a date column (orders, documents, customers, products by release date) use it directly or via a parent extension.
Why do filter bugs in Shopware 6 admin sometimes show up only in Q1? Quarter-based date math often uses the current year for both the start and end of the previous quarter. From April through December, the previous quarter starts and ends in the same calendar year, so the bug is invisible. In January, February, and March, the previous quarter starts in the previous year and ends in the previous year, but the buggy code derives the end year from today, so the range stretches across two years. PR #16380 fixed exactly this in sw-date-filterâs getPreviousQuarterDates.
How does Shopware 6 handle user timezones in admin date filters? Each admin user has a timeZone on their profile, accessible via Shopware.Store.get('session').currentUser.timeZone. List date columns format display values in that timezone. Filter inputs need to do the same when deriving day boundaries, otherwise the picked day in the UI does not match the day used in the query. PR #16439 introduced a dayBoundsInTz helper that uses Intl.DateTimeFormat to snap the picked instant to the userâs calendar day before emitting gte/lte bounds.
Should I override or decorate Shopware 6 admin components? Component overrides in the admin are extension, not replacement. Your override layers on top of the base component and inherits its computed properties, methods, and templates unless you explicitly replace them. That is the design strength, but it also means any bug in the parent is now a bug in your override. For services in PHP, decoration is the equivalent. The pattern only works in your favor if the parent is correct. When it is not, you either patch the parent locally or contribute upstream. I covered the broader pattern in Rethinking Shopware 6 Plugin Development.
How long does it take for a Shopware core PR to ship to a release? Both PRs in this post are tagged with the milestone label milestone/6.7.10.0, which is how Shopware schedules a fix into a specific patch release. Time from PR to release depends on review queue, milestone freeze date, and whether the change touches multiple areas. For non-blocking admin fixes, expect days to weeks. If a client cannot wait for the next patch release, the fix can be applied locally in a plugin override and removed once the upstream version ships.