Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# ISSUE 01 — History activity API ignores temporal/entity context

## Priority
P0 (High)

## Proposal Relevance
Directly impacts the Time-Travel proposal goal: temporal query propagation and coherent temporal UX across history workflows.

## Issue Description
`/api/history/activity` currently returns synthetic buckets based only on `period`, with a fixed anchor date and computed counts. It does not use selected entity type, `$as_of`, or `$from_to`.

## Evidence
- `ui/app/api/history/activity/route.ts`
- Reads only `period`
- Uses fixed date `2024-08-20T00:00:00Z`
- Generates counts with deterministic formula, not dataset-derived data

## Validation Status
- Static validation: Confirmed from implementation path.
- Runtime validation: 11 unit tests added and passing covering all filter combinations.
- Full test suite: 18/18 tests passing across 4 suites.
- Browser verification: API responses and History page UI confirmed correct.

## Reproduction
1. Open `/history`.
2. Change temporal mode between `current`, `as_of`, `from_to`.
3. Switch entity tabs.
4. Observe activity graph pattern stays synthetic and detached from query context.

## Expected vs Actual
- Expected: Activity buckets derived from filtered history/commit data for selected entity + temporal scope.
- Actual: Synthetic buckets independent from temporal/entity filters.

## Fix Applied
1. Extracted shared `COMMITS`, `SNAPSHOTS`, `ENTITY_TYPES`, `parseEntityType` into `ui/app/api/history/data.ts`.
2. Added `buildActivityBuckets()` pure function that filters commits by `entityType`, `$as_of`, `$from_to` and builds date-bucketed activity counts.
3. Simplified `activity/route.ts` to delegate to `buildActivityBuckets()`.
4. Refactored `history/route.ts` to import from shared module (no logic change).
5. Updated `page.tsx` to pass temporal params to the activity endpoint URL via `appendTemporalParams`.
6. Kept deterministic zero-count fallback for empty filter results.

## Files Changed
- `ui/app/api/history/data.ts` — **NEW** shared data + `buildActivityBuckets()` logic
- `ui/app/api/history/activity/route.ts` — **MODIFIED** uses shared function
- `ui/app/api/history/route.ts` — **MODIFIED** imports from shared module
- `ui/app/history/page.tsx` — **MODIFIED** activity URL includes temporal params
- `ui/__tests__/history-activity.test.ts` — **NEW** 11 unit tests

## Draft GitHub Issue
### Title
History activity endpoint ignores selected entity and temporal scope

### Problem
The activity graph is currently backed by synthetic data not linked to selected entity or temporal mode, creating a mismatch with the history query preview and user expectations.

### Steps to Reproduce
1. Go to `/history`.
2. Switch entity tab and temporal mode.
3. Observe activity graph output.

### Expected
Activity data changes according to entity and temporal filters.

### Actual
Activity data remains formula-generated and detached.

### Acceptance Criteria
- Activity API accepts and applies `entityType`, `$as_of`, `$from_to`.
- Buckets are computed from filtered commits.
- Graph output changes when temporal/entity filters change.

## Draft PR Description
### Summary
Wire activity endpoint to temporal-aware history data instead of synthetic buckets.

### Root Cause
Mock activity generator bypasses temporal/entity query context.

### Changes
- Extract shared data and filtering logic into `data.ts` with a pure `buildActivityBuckets()` function.
- Activity route delegates to `buildActivityBuckets()` with parsed query params.
- History route refactored to import from shared module (zero logic change).
- Frontend `page.tsx` passes temporal params to the activity endpoint via `appendTemporalParams`.
- Zero-count fallback preserved for empty filter results.

### Test Plan
- 11 unit tests covering: default entity, per-entity filtering, `$as_of` inclusion/exclusion, `$from_to` range filtering, empty result fallback, structural validation, cross-entity differentiation.
- Full test suite: 18/18 passing.
- Browser verification: API JSON responses and History page UI confirmed correct.

### Risks
- Slight behavior change in demo data shape (activity buckets now reflect real commit dates instead of formula-generated patterns).
- Zero-count fallback ensures empty states still render a valid 28-day grid.
123 changes: 123 additions & 0 deletions ui/__tests__/history-activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2025 SUPSI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { buildActivityBuckets, COMMITS } from '@/app/api/history/data'

describe('History Activity API — buildActivityBuckets()', () => {
it('returns buckets derived from Thing commits by default', () => {
const buckets = buildActivityBuckets({})

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
const thingCommits = COMMITS.filter((c) => c.entityType === 'Thing')
expect(totalCount).toBe(thingCommits.length)
})

it('filters by entityType=Sensor', () => {
const buckets = buildActivityBuckets({ entityType: 'Sensor' })

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
const sensorCommits = COMMITS.filter((c) => c.entityType === 'Sensor')
expect(totalCount).toBe(sensorCommits.length)
})

it('filters by entityType=Location', () => {
const buckets = buildActivityBuckets({ entityType: 'Location' })

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
const locationCommits = COMMITS.filter((c) => c.entityType === 'Location')
expect(totalCount).toBe(locationCommits.length)
})

it('applies $as_of filter — excludes future commits', () => {
// Thing commit is at 2024-06-01, $as_of before that should yield 0
const buckets = buildActivityBuckets({
entityType: 'Thing',
$as_of: '2024-01-01T00:00:00Z',
})

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
expect(totalCount).toBe(0)
})

it('applies $as_of filter — includes commits at or before date', () => {
// Thing commit is at 2024-06-01T08:00:00Z
const buckets = buildActivityBuckets({
entityType: 'Thing',
$as_of: '2024-07-01T00:00:00Z',
})

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
expect(totalCount).toBe(1)
})

it('applies $from_to filter — only commits in range', () => {
const buckets = buildActivityBuckets({
entityType: 'Sensor',
$from_to: '2024-03-01T00:00:00Z,2024-03-31T23:59:59Z',
})

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
expect(totalCount).toBe(1) // Only the Sensor commit in March
})

it('applies $from_to filter — excludes commits outside range', () => {
const buckets = buildActivityBuckets({
entityType: 'Sensor',
$from_to: '2025-01-01T00:00:00Z,2025-12-31T23:59:59Z',
})

const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
expect(totalCount).toBe(0) // No Sensor commits in 2025
})

it('returns zero-count fallback buckets when no commits match', () => {
const buckets = buildActivityBuckets({
entityType: 'Thing',
$as_of: '2020-01-01T00:00:00Z', // Before all commits
})

expect(buckets.length).toBe(28)
const totalCount = buckets.reduce((sum, b) => sum + b.count, 0)
expect(totalCount).toBe(0)
})

it('returns 28 buckets for non-empty results', () => {
const buckets = buildActivityBuckets({ entityType: 'Thing' })
expect(buckets.length).toBe(28)
})

it('each bucket has a date string and numeric count', () => {
const buckets = buildActivityBuckets({ entityType: 'Thing' })

for (const bucket of buckets) {
expect(typeof bucket.date).toBe('string')
expect(bucket.date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(typeof bucket.count).toBe('number')
expect(bucket.count).toBeGreaterThanOrEqual(0)
}
})

it('different entity types produce different activity data', () => {
const thingBuckets = buildActivityBuckets({ entityType: 'Thing' })
const sensorBuckets = buildActivityBuckets({ entityType: 'Sensor' })

// Thing commit is on 2024-06-01, Sensor commit is on 2024-03-11
const thingActive = thingBuckets.filter((b) => b.count > 0)
const sensorActive = sensorBuckets.filter((b) => b.count > 0)

expect(thingActive[0].date).not.toBe(sensorActive[0].date)
})
})
54 changes: 54 additions & 0 deletions ui/__tests__/temporal-badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2025 SUPSI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react'
import React from 'react'

import TemporalBadge from '@/components/TemporalBadge'

const resetMock = jest.fn()

const temporalMock = {
mode: 'current',
asOf: null,
fromTo: null,
reset: resetMock,
}

jest.mock('@/context/TemporalContext', () => ({
useTemporal: () => temporalMock,
}))

describe('TemporalBadge', () => {
it('renders live badge for current mode', () => {
temporalMock.mode = 'current'
render(<TemporalBadge />)
expect(screen.getByText(/Live/i)).toBeInTheDocument()
})

it('renders as-of badge text', () => {
temporalMock.mode = 'as_of'
temporalMock.asOf = '2024-02-01T10:30:00Z'
render(<TemporalBadge />)
expect(screen.getByTestId('temporal-badge-as-of')).toBeInTheDocument()
})

it('renders from-to badge text', () => {
temporalMock.mode = 'from_to'
temporalMock.fromTo = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']
render(<TemporalBadge />)
expect(screen.getByTestId('temporal-badge-from-to')).toBeInTheDocument()
})
})
64 changes: 64 additions & 0 deletions ui/__tests__/temporal-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2025 SUPSI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, render, screen } from '@testing-library/react'
import React from 'react'

import { TemporalProvider, useTemporal } from '@/context/TemporalContext'

const replaceMock = jest.fn()
const paramsState = new URLSearchParams('')

jest.mock('next/navigation', () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => '/things',
useSearchParams: () => paramsState,
}))

function Consumer() {
const temporal = useTemporal()
return (
<>
<span data-testid="mode">{temporal.mode}</span>
<button onClick={() => temporal.setAsOf('2024-02-01T10:30:00Z')}>setAsOf</button>
<button onClick={() => temporal.reset()}>reset</button>
</>
)
}

describe('TemporalContext', () => {
it('starts in current mode and supports as_of + reset', async () => {
render(
<TemporalProvider>
<Consumer />
</TemporalProvider>
)

expect(screen.getByTestId('mode')).toHaveTextContent('current')

await act(async () => {
screen.getByText('setAsOf').click()
})

expect(screen.getByTestId('mode')).toHaveTextContent('as_of')

await act(async () => {
screen.getByText('reset').click()
})

expect(screen.getByTestId('mode')).toHaveTextContent('current')
expect(replaceMock).toHaveBeenCalled()
})
})
60 changes: 60 additions & 0 deletions ui/__tests__/temporal-mode-switch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2025 SUPSI
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react'
import React from 'react'

import TemporalModeSwitch from '@/components/TemporalModeSwitch'

const contextMock = {
mode: 'current',
asOf: null,
fromTo: null,
setMode: jest.fn(),
setAsOf: jest.fn(),
setFromTo: jest.fn(),
}

jest.mock('@/context/TemporalContext', () => ({
useTemporal: () => contextMock,
}))

describe('TemporalModeSwitch', () => {
it('renders all three mode tabs', () => {
contextMock.mode = 'current'
render(<TemporalModeSwitch />)

expect(screen.getByText('Current')).toBeInTheDocument()
expect(screen.getByText('As-of')).toBeInTheDocument()
expect(screen.getByText('From-to')).toBeInTheDocument()
})

it('shows as-of input in as_of mode', () => {
contextMock.mode = 'as_of'
contextMock.asOf = '2024-02-01T10:30:00Z'
render(<TemporalModeSwitch />)

expect(screen.getByLabelText('As-of timestamp')).toBeInTheDocument()
})

it('shows two datetime inputs in from_to mode', () => {
contextMock.mode = 'from_to'
contextMock.fromTo = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z']
render(<TemporalModeSwitch />)

expect(screen.getByLabelText('From')).toBeInTheDocument()
expect(screen.getByLabelText('To')).toBeInTheDocument()
})
})
Loading