From 5e36806392f66d58e767620e932465c11d60a38b Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Mon, 27 Apr 2026 15:37:09 +0200 Subject: [PATCH] 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. --- mobile/app/(tabs)/search.tsx | 29 +++++++++++++++++------------ mobile/db/queries.ts | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/mobile/app/(tabs)/search.tsx b/mobile/app/(tabs)/search.tsx index e938398..1cfbdd8 100644 --- a/mobile/app/(tabs)/search.tsx +++ b/mobile/app/(tabs)/search.tsx @@ -1,10 +1,9 @@ import { useState } from 'react'; 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 { useTheme } from '@/ThemeContext'; -type DatePreset = 'all' | '7d' | '30d' | '6mo' | 'year'; type SortKey = 'date' | 'distance' | 'elevation'; const SPORTS = [ @@ -16,12 +15,11 @@ const SPORTS = [ { value: 'walking', label: '🚶 Walking' }, ]; -const DATE_PRESETS: { value: DatePreset; label: string }[] = [ +const DATE_PRESETS = [ { value: 'all', label: 'All time' }, { value: '7d', label: '7 days' }, { value: '30d', label: '30 days' }, { value: '6mo', label: '6 months' }, - { value: 'year', label: 'This year' }, ]; const SORTS: { value: SortKey; label: string }[] = [ @@ -30,26 +28,33 @@ const SORTS: { value: SortKey; label: string }[] = [ { value: 'elevation', label: 'Elevation' }, ]; -function computeDateFrom(preset: DatePreset): string { - if (preset === 'all') return ''; +function computeDateRange(preset: string): { dateFrom: string; dateTo: string } { + 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 now = new Date(); let d: Date; 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 === '6mo') { d = new Date(now); d.setMonth(d.getMonth() - 6); } - else d = new Date(now.getFullYear(), 0, 1); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`; + else { d = new Date(now); d.setMonth(d.getMonth() - 6); } + return { dateFrom: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`, dateTo: '' }; } export default function SearchScreen() { const theme = useTheme(); const [sport, setSport] = useState(''); - const [datePre, setDatePre] = useState('all'); + const [datePre, setDatePre] = useState('all'); const [sort, setSort] = useState('date'); 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 total = useFilteredCount(filter); const hasMore = activities.length < total; @@ -71,7 +76,7 @@ export default function SearchScreen() { - {DATE_PRESETS.map(d => ( + {dateOptions.map(d => ( { setDatePre(d.value); setLimit(PAGE_SIZE); }} /> ))} diff --git a/mobile/db/queries.ts b/mobile/db/queries.ts index f698ec8..deb3607 100644 --- a/mobile/db/queries.ts +++ b/mobile/db/queries.ts @@ -70,6 +70,7 @@ export { PAGE_SIZE }; export type ActivityFilter = { sport: string; // '' = all sports dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison + dateTo: string; // '' = no upper bound sort: 'date' | 'distance' | 'elevation'; }; @@ -95,9 +96,10 @@ export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE) FROM activities WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?) + AND (? = '' OR json_extract(detail_json, '$.started_at') < ?) ORDER BY ${order} 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 { @@ -106,10 +108,22 @@ export function useFilteredCount(filter: ActivityFilter): number { SELECT COUNT(*) as n FROM activities WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) 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; } +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 { const db = useSQLiteContext(); return db.getFirstSync(