Sprint: ll (#206)
* feat(jobs): voeg lib/jobs-time-filter.ts toe met tijdvenster-predikaat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * test(jobs-time-filter): voeg unit-tests toe voor isWithinTimeWindow en UserSettings-schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ea28a62973
commit
3ad352c10f
7 changed files with 152 additions and 1 deletions
57
__tests__/lib/jobs-time-filter.test.ts
Normal file
57
__tests__/lib/jobs-time-filter.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -107,6 +107,7 @@ describe('UserSettingsSchema', () => {
|
||||||
pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' },
|
pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' },
|
||||||
storyPanel: { sort: 'date' },
|
storyPanel: { sort: 'date' },
|
||||||
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
|
jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } },
|
||||||
|
jobs: { timeFilter: '24h' },
|
||||||
},
|
},
|
||||||
devTools: { debugMode: true },
|
devTools: { debugMode: true },
|
||||||
layout: {
|
layout: {
|
||||||
|
|
@ -117,6 +118,17 @@ describe('UserSettingsSchema', () => {
|
||||||
expect(result.success).toBe(true)
|
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', () => {
|
it('accepts layout-only settings', () => {
|
||||||
expect(UserSettingsSchema.safeParse({
|
expect(UserSettingsSchema.safeParse({
|
||||||
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { redirect } from 'next/navigation'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { fetchJobsPageData } from '@/actions/jobs-page'
|
import { fetchJobsPageData } from '@/actions/jobs-page'
|
||||||
import JobsBoard from '@/components/jobs/jobs-board'
|
import JobsBoard from '@/components/jobs/jobs-board'
|
||||||
|
import JobsTimeFilter from '@/components/jobs/jobs-time-filter'
|
||||||
|
|
||||||
export const metadata = { title: 'Jobs — Scrum4Me' }
|
export const metadata = { title: 'Jobs — Scrum4Me' }
|
||||||
|
|
||||||
|
|
@ -14,8 +15,9 @@ export default async function JobsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b shrink-0 flex items-center gap-3">
|
<div className="px-6 py-4 border-b shrink-0 flex items-center justify-between gap-3">
|
||||||
<h1 className="text-lg font-semibold">Jobs</h1>
|
<h1 className="text-lg font-semibold">Jobs</h1>
|
||||||
|
<JobsTimeFilter />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} isDemo={session.isDemo ?? false} />
|
<JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} isDemo={session.isDemo ?? false} />
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import JobCard from './job-card'
|
||||||
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
import { JOB_STATUS_LABELS } from '@/components/shared/job-status'
|
||||||
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||||
|
import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { debugProps } from '@/lib/debug'
|
import { debugProps } from '@/lib/debug'
|
||||||
import type { JobWithRelations } from '@/actions/jobs-page'
|
import type { JobWithRelations } from '@/actions/jobs-page'
|
||||||
|
|
@ -112,6 +113,9 @@ export default function JobsColumn({
|
||||||
const colPrefs = useUserSettingsStore(
|
const colPrefs = useUserSettingsStore(
|
||||||
useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]),
|
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 setPref = useUserSettingsStore((s) => s.setPref)
|
||||||
|
|
||||||
const filterKinds = useMemo<Set<ClaudeJobKind>>(() => {
|
const filterKinds = useMemo<Set<ClaudeJobKind>>(() => {
|
||||||
|
|
@ -152,6 +156,7 @@ export default function JobsColumn({
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = jobs.filter((j) => {
|
const filtered = jobs.filter((j) => {
|
||||||
|
if (!isWithinTimeWindow(j.createdAt, timeFilter)) return false
|
||||||
if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
|
if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false
|
||||||
if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
|
if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
48
components/jobs/jobs-time-filter.tsx
Normal file
48
components/jobs/jobs-time-filter.tsx
Normal file
|
|
@ -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<JobsTimeFilter, string> = {
|
||||||
|
'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 (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
{...debugProps('jobs-time-filter', 'JobsTimeFilter', 'components/jobs/jobs-time-filter.tsx')}
|
||||||
|
>
|
||||||
|
{JOBS_TIME_FILTER_VALUES.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void setPref(['views', 'jobs', 'timeFilter'], v)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2.5 py-1 rounded-full border transition-colors',
|
||||||
|
current === v
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-transparent border-border hover:bg-surface-container',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{LABELS[v]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
lib/jobs-time-filter.ts
Normal file
21
lib/jobs-time-filter.ts
Normal file
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { JOBS_TIME_FILTER_VALUES } from '@/lib/jobs-time-filter'
|
||||||
|
|
||||||
const PriorityFilter = z.union([
|
const PriorityFilter = z.union([
|
||||||
z.number().int().min(1).max(4),
|
z.number().int().min(1).max(4),
|
||||||
|
|
@ -32,11 +33,16 @@ const JobsColumnPrefs = z.object({
|
||||||
statuses: z.array(z.string()),
|
statuses: z.array(z.string()),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
|
const JobsViewPrefs = z.object({
|
||||||
|
timeFilter: z.enum(JOBS_TIME_FILTER_VALUES).optional(),
|
||||||
|
}).strict()
|
||||||
|
|
||||||
const ViewsPrefs = z.object({
|
const ViewsPrefs = z.object({
|
||||||
sprintBacklog: SprintBacklogPrefs.optional(),
|
sprintBacklog: SprintBacklogPrefs.optional(),
|
||||||
pbiList: PbiListPrefs.optional(),
|
pbiList: PbiListPrefs.optional(),
|
||||||
storyPanel: StoryPanelPrefs.optional(),
|
storyPanel: StoryPanelPrefs.optional(),
|
||||||
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
|
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
|
||||||
|
jobs: JobsViewPrefs.optional(),
|
||||||
}).strict()
|
}).strict()
|
||||||
|
|
||||||
const DevToolsPrefs = z.object({
|
const DevToolsPrefs = z.object({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue