feat(mobile): replace 'This year' with dynamic per-year pills in filter tab

Date row now shows All time | 7d | 30d | 6mo | 2026 | 2025 | ... derived
from actual activity data. Year pills use a bounded [Jan 1, Jan 1+1) range
via a new dateTo field on ActivityFilter; rolling-window presets keep an
open upper bound.
This commit is contained in:
Davide Scaini
2026-04-27 15:37:09 +02:00
parent 87baf33815
commit 5e36806392
2 changed files with 33 additions and 14 deletions
+17 -12
View File
@@ -1,10 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { PAGE_SIZE, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries'; import { PAGE_SIZE, useActivityYears, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries';
import { ActivityCard } from '@/components/ActivityCard'; import { ActivityCard } from '@/components/ActivityCard';
import { useTheme } from '@/ThemeContext'; import { useTheme } from '@/ThemeContext';
type DatePreset = 'all' | '7d' | '30d' | '6mo' | 'year';
type SortKey = 'date' | 'distance' | 'elevation'; type SortKey = 'date' | 'distance' | 'elevation';
const SPORTS = [ const SPORTS = [
@@ -16,12 +15,11 @@ const SPORTS = [
{ value: 'walking', label: '🚶 Walking' }, { value: 'walking', label: '🚶 Walking' },
]; ];
const DATE_PRESETS: { value: DatePreset; label: string }[] = [ const DATE_PRESETS = [
{ value: 'all', label: 'All time' }, { value: 'all', label: 'All time' },
{ value: '7d', label: '7 days' }, { value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' }, { value: '30d', label: '30 days' },
{ value: '6mo', label: '6 months' }, { value: '6mo', label: '6 months' },
{ value: 'year', label: 'This year' },
]; ];
const SORTS: { value: SortKey; label: string }[] = [ const SORTS: { value: SortKey; label: string }[] = [
@@ -30,26 +28,33 @@ const SORTS: { value: SortKey; label: string }[] = [
{ value: 'elevation', label: 'Elevation' }, { value: 'elevation', label: 'Elevation' },
]; ];
function computeDateFrom(preset: DatePreset): string { function computeDateRange(preset: string): { dateFrom: string; dateTo: string } {
if (preset === 'all') return ''; if (preset === 'all') return { dateFrom: '', dateTo: '' };
if (/^\d{4}$/.test(preset)) {
const y = parseInt(preset, 10);
return { dateFrom: `${y}-01-01T000000Z`, dateTo: `${y + 1}-01-01T000000Z` };
}
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const now = new Date(); const now = new Date();
let d: Date; let d: Date;
if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000); if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000);
else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000); else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000);
else if (preset === '6mo') { d = new Date(now); d.setMonth(d.getMonth() - 6); } else { d = new Date(now); d.setMonth(d.getMonth() - 6); }
else d = new Date(now.getFullYear(), 0, 1); return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' };
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`;
} }
export default function SearchScreen() { export default function SearchScreen() {
const theme = useTheme(); const theme = useTheme();
const [sport, setSport] = useState(''); const [sport, setSport] = useState('');
const [datePre, setDatePre] = useState<DatePreset>('all'); const [datePre, setDatePre] = useState('all');
const [sort, setSort] = useState<SortKey>('date'); const [sort, setSort] = useState<SortKey>('date');
const [limit, setLimit] = useState(PAGE_SIZE); const [limit, setLimit] = useState(PAGE_SIZE);
const filter: ActivityFilter = { sport, dateFrom: computeDateFrom(datePre), sort }; const years = useActivityYears();
const dateOptions = [...DATE_PRESETS, ...years.map(y => ({ value: y, label: y }))];
const { dateFrom, dateTo } = computeDateRange(datePre);
const filter: ActivityFilter = { sport, dateFrom, dateTo, sort };
const activities = useFilteredActivities(filter, limit); const activities = useFilteredActivities(filter, limit);
const total = useFilteredCount(filter); const total = useFilteredCount(filter);
const hasMore = activities.length < total; const hasMore = activities.length < total;
@@ -71,7 +76,7 @@ export default function SearchScreen() {
<ScrollView horizontal showsHorizontalScrollIndicator={false} <ScrollView horizontal showsHorizontalScrollIndicator={false}
style={styles.pillScroll} contentContainerStyle={styles.pillRow}> style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
{DATE_PRESETS.map(d => ( {dateOptions.map(d => (
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent} <Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} /> onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
))} ))}
+16 -2
View File
@@ -70,6 +70,7 @@ export { PAGE_SIZE };
export type ActivityFilter = { export type ActivityFilter = {
sport: string; // '' = all sports sport: string; // '' = all sports
dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison
dateTo: string; // '' = no upper bound
sort: 'date' | 'distance' | 'elevation'; sort: 'date' | 'distance' | 'elevation';
}; };
@@ -95,9 +96,10 @@ export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE)
FROM activities FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?) AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
ORDER BY ${order} ORDER BY ${order}
LIMIT ? LIMIT ?
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, limit]); `, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo, limit]);
} }
export function useFilteredCount(filter: ActivityFilter): number { export function useFilteredCount(filter: ActivityFilter): number {
@@ -106,10 +108,22 @@ export function useFilteredCount(filter: ActivityFilter): number {
SELECT COUNT(*) as n FROM activities SELECT COUNT(*) as n FROM activities
WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?)
AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?) AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?)
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom]); AND (? = '' OR json_extract(detail_json, '$.started_at') < ?)
`, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, filter.dateTo, filter.dateTo]);
return row?.n ?? 0; return row?.n ?? 0;
} }
export function useActivityYears(): string[] {
const db = useSQLiteContext();
const rows = db.getAllSync<{ year: string }>(
`SELECT DISTINCT substr(json_extract(detail_json, '$.started_at'), 1, 4) AS year
FROM activities
WHERE json_extract(detail_json, '$.started_at') IS NOT NULL
ORDER BY year DESC`,
);
return rows.map(r => r.year).filter(Boolean);
}
export function useActivity(id: string): ActivityRow | null { export function useActivity(id: string): ActivityRow | null {
const db = useSQLiteContext(); const db = useSQLiteContext();
return db.getFirstSync<ActivityRow>( return db.getFirstSync<ActivityRow>(