From 3ad352c10f3b8a38d3871515cc906047f0432b7c Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 21:06:59 +0200 Subject: [PATCH] Sprint: ll (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(jobs): voeg lib/jobs-time-filter.ts toe met tijdvenster-predikaat Co-Authored-By: Claude Sonnet 4.6 * feat(user-settings): voeg views.jobs.timeFilter toe aan UserSettingsSchema Breidt ViewsPrefs uit met een jobs-object (JobsViewPrefs) dat timeFilter accepteert met waarden '1h' | '24h' | 'all'. ViewsPrefs blijft .strict(). Co-Authored-By: Claude Sonnet 4.6 * test(jobs-time-filter): voeg unit-tests toe voor isWithinTimeWindow en UserSettings-schema Co-Authored-By: Claude Sonnet 4.6 * feat(jobs-time-filter): voeg JobsTimeFilterControl component toe Nieuw client-component dat views.jobs.timeFilter leest/schrijft via useUserSettingsStore met pill-stijl (MD3-tokens). Co-Authored-By: Claude Sonnet 4.6 * feat(jobs): wire JobsTimeFilter in jobs page header Plaatst het tijdfilter-component rechts van de Jobs-kop via justify-between op de header-div. Co-Authored-By: Claude Sonnet 4.6 * feat(jobs): pas tijdvenster-filter toe in JobsColumn Lees views.jobs.timeFilter uit de store en filter jobs op createdAt via isWithinTimeWindow, als eerste check vóór de bestaande kind/status-filters. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- __tests__/lib/jobs-time-filter.test.ts | 57 ++++++++++++++++++++++++++ __tests__/lib/user-settings.test.ts | 12 ++++++ app/(app)/jobs/page.tsx | 4 +- components/jobs/jobs-column.tsx | 5 +++ components/jobs/jobs-time-filter.tsx | 48 ++++++++++++++++++++++ lib/jobs-time-filter.ts | 21 ++++++++++ lib/user-settings.ts | 6 +++ 7 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 __tests__/lib/jobs-time-filter.test.ts create mode 100644 components/jobs/jobs-time-filter.tsx create mode 100644 lib/jobs-time-filter.ts diff --git a/__tests__/lib/jobs-time-filter.test.ts b/__tests__/lib/jobs-time-filter.test.ts new file mode 100644 index 0000000..3e1be4b --- /dev/null +++ b/__tests__/lib/jobs-time-filter.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { isWithinTimeWindow } from '@/lib/jobs-time-filter' + +const HOUR_MS = 60 * 60 * 1000 + +describe('isWithinTimeWindow', () => { + it("returns true for filter='all' regardless of age", () => { + const old = new Date(0) + expect(isWithinTimeWindow(old, 'all')).toBe(true) + }) + + describe("filter='1h'", () => { + const now = Date.now() + + it('returns true for a job created 30 minutes ago', () => { + const createdAt = new Date(now - 30 * 60 * 1000) + expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true) + }) + + it('returns false for a job created 90 minutes ago', () => { + const createdAt = new Date(now - 90 * 60 * 1000) + expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false) + }) + }) + + describe("filter='24h'", () => { + const now = Date.now() + + it('returns true for a job created 23 hours ago', () => { + const createdAt = new Date(now - 23 * HOUR_MS) + expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true) + }) + + it('returns false for a job created 25 hours ago', () => { + const createdAt = new Date(now - 25 * HOUR_MS) + expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false) + }) + }) + + describe('accepts both Date and ISO string for createdAt', () => { + const now = Date.now() + const recent = new Date(now - 30 * 60 * 1000) + + it('accepts a Date object', () => { + expect(isWithinTimeWindow(recent, '1h', now)).toBe(true) + }) + + it('accepts an ISO string', () => { + expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true) + }) + }) + + it('returns true for an invalid date string (fail-open)', () => { + expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true) + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index 1bff8ea..d62648a 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -107,6 +107,7 @@ describe('UserSettingsSchema', () => { pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, storyPanel: { sort: 'date' }, jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, + jobs: { timeFilter: '24h' }, }, devTools: { debugMode: true }, layout: { @@ -117,6 +118,17 @@ describe('UserSettingsSchema', () => { expect(result.success).toBe(true) }) + it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => { + const input = { views: { jobs: { timeFilter: '1h' as const } } } + const result = parseUserSettings(input) + expect(result).toEqual(input) + }) + + it('rejects an invalid views.jobs.timeFilter value', () => { + const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } }) + expect(result.success).toBe(false) + }) + it('accepts layout-only settings', () => { expect(UserSettingsSchema.safeParse({ layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx index 3731e6c..25c571b 100644 --- a/app/(app)/jobs/page.tsx +++ b/app/(app)/jobs/page.tsx @@ -2,6 +2,7 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { fetchJobsPageData } from '@/actions/jobs-page' import JobsBoard from '@/components/jobs/jobs-board' +import JobsTimeFilter from '@/components/jobs/jobs-time-filter' export const metadata = { title: 'Jobs — Scrum4Me' } @@ -14,8 +15,9 @@ export default async function JobsPage() { return (
-
+

Jobs

+
diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index a18a58a..f535e40 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -8,6 +8,7 @@ import JobCard from './job-card' import { JOB_STATUS_LABELS } from '@/components/shared/job-status' import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status' import { useUserSettingsStore } from '@/stores/user-settings/store' +import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter' import { cn } from '@/lib/utils' import { debugProps } from '@/lib/debug' import type { JobWithRelations } from '@/actions/jobs-page' @@ -112,6 +113,9 @@ export default function JobsColumn({ const colPrefs = useUserSettingsStore( useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]), ) + const timeFilter = useUserSettingsStore( + useShallow((s) => s.entities.settings.views?.jobs?.timeFilter), + ) ?? DEFAULT_JOBS_TIME_FILTER const setPref = useUserSettingsStore((s) => s.setPref) const filterKinds = useMemo>(() => { @@ -152,6 +156,7 @@ export default function JobsColumn({ } const filtered = jobs.filter((j) => { + if (!isWithinTimeWindow(j.createdAt, timeFilter)) return false if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false return true diff --git a/components/jobs/jobs-time-filter.tsx b/components/jobs/jobs-time-filter.tsx new file mode 100644 index 0000000..9615003 --- /dev/null +++ b/components/jobs/jobs-time-filter.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useShallow } from 'zustand/react/shallow' +import { useUserSettingsStore } from '@/stores/user-settings/store' +import { cn } from '@/lib/utils' +import { debugProps } from '@/lib/debug' +import { + JOBS_TIME_FILTER_VALUES, + DEFAULT_JOBS_TIME_FILTER, + type JobsTimeFilter, +} from '@/lib/jobs-time-filter' + +const LABELS: Record = { + '1h': '1 uur', + '24h': '24 uur', + all: 'Alles', +} + +export default function JobsTimeFilterControl() { + const current = + useUserSettingsStore( + useShallow((s) => s.entities.settings.views?.jobs?.timeFilter), + ) ?? DEFAULT_JOBS_TIME_FILTER + const setPref = useUserSettingsStore((s) => s.setPref) + + return ( +
+ {JOBS_TIME_FILTER_VALUES.map((v) => ( + + ))} +
+ ) +} diff --git a/lib/jobs-time-filter.ts b/lib/jobs-time-filter.ts new file mode 100644 index 0000000..405248e --- /dev/null +++ b/lib/jobs-time-filter.ts @@ -0,0 +1,21 @@ +export const JOBS_TIME_FILTER_VALUES = ['1h', '24h', 'all'] as const; + +export type JobsTimeFilter = (typeof JOBS_TIME_FILTER_VALUES)[number]; + +export const DEFAULT_JOBS_TIME_FILTER: JobsTimeFilter = 'all'; + +const WINDOW_MS: Record<'1h' | '24h', number> = { + '1h': 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, +}; + +export function isWithinTimeWindow( + createdAt: Date | string, + filter: JobsTimeFilter, + now: number = Date.now(), +): boolean { + if (filter === 'all') return true; + const ts = new Date(createdAt).getTime(); + if (Number.isNaN(ts)) return true; + return ts >= now - WINDOW_MS[filter]; +} diff --git a/lib/user-settings.ts b/lib/user-settings.ts index 0bcb92f..e294dc9 100644 --- a/lib/user-settings.ts +++ b/lib/user-settings.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { JOBS_TIME_FILTER_VALUES } from '@/lib/jobs-time-filter' const PriorityFilter = z.union([ z.number().int().min(1).max(4), @@ -32,11 +33,16 @@ const JobsColumnPrefs = z.object({ statuses: z.array(z.string()), }).strict() +const JobsViewPrefs = z.object({ + timeFilter: z.enum(JOBS_TIME_FILTER_VALUES).optional(), +}).strict() + const ViewsPrefs = z.object({ sprintBacklog: SprintBacklogPrefs.optional(), pbiList: PbiListPrefs.optional(), storyPanel: StoryPanelPrefs.optional(), jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(), + jobs: JobsViewPrefs.optional(), }).strict() const DevToolsPrefs = z.object({