Skip to content

Commit cdff5e4

Browse files
Merge pull request #9 from biranchikulesika/fix/ContributionsCount
Fixed contributions count local storage
2 parents f76e211 + caf71bc commit cdff5e4

5 files changed

Lines changed: 178 additions & 34 deletions

File tree

frontend/components/lipyd/ContributorSetup.tsx

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import React, { useState, useEffect } from 'react';
33
import CharacterSearch from './CharacterSearch';
44
import { generateSessionId } from '@/lib/lipyd/filenameService';
5-
import { getAllSamples, saveContributor } from '@/lib/lipyd/storageService';
5+
import { saveContributor, getLifetimeCount, clearContributorData, getUploadedSampleCount } from '@/lib/lipyd/storageService';
66
import { OdiaCharacter } from '@/lib/lipyd/odiaCharacters';
77
import { exportDataset } from '@/lib/lipyd/exportService';
88

@@ -75,6 +75,23 @@ export default function ContributorSetup({ onStart, isSearchFocused, onSearchFoc
7575

7676
useEffect(() => {
7777
if (!contributorId) {
78+
try {
79+
const savedId = getCookie('lipy_contributorId');
80+
if (savedId) {
81+
setContributorId(savedId);
82+
return;
83+
}
84+
} catch (e) { }
85+
try {
86+
const raw = localStorage.getItem('lipy_session_config');
87+
if (raw) {
88+
const cfg = JSON.parse(raw);
89+
if (cfg?.contributorId) {
90+
setContributorId(cfg.contributorId);
91+
return;
92+
}
93+
}
94+
} catch (e) { }
7895
try {
7996
const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `c_${Math.random().toString(36).slice(2, 9)}`;
8097
setContributorId(id);
@@ -94,24 +111,14 @@ export default function ContributorSetup({ onStart, isSearchFocused, onSearchFoc
94111
}
95112

96113
try {
97-
const devKey = `lipy_device_sample_count_${String(contributorId || '').trim()}`;
98-
const cachedDev = Number(localStorage.getItem(devKey) || 0) || 0;
99-
100-
const samples = await getAllSamples();
101-
const dbCount = samples.filter((sample) => {
102-
try {
103-
const sCid = sample?.contributorId != null ? String(sample.contributorId).trim() : '';
104-
const cCid = String(contributorId || '').trim();
105-
return sCid && cCid && sCid === cCid;
106-
} catch (e) {
107-
return false;
108-
}
109-
}).length;
110-
111-
const count = Math.max(dbCount, cachedDev);
112-
if (count > cachedDev) {
113-
try { localStorage.setItem(devKey, String(count)); } catch(e) {}
114-
}
114+
// Lifetime count = successfully *synchronized* samples (durable across
115+
// sessions). We reconcile the localStorage cache with the IndexedDB
116+
// uploaded count so the displayed number stays accurate even if one
117+
// source was cleared. Only uploaded samples count toward the lifetime
118+
// total.
119+
const cachedLifetime = getLifetimeCount(contributorId);
120+
const uploadedCount = await getUploadedSampleCount(contributorId);
121+
const count = Math.max(uploadedCount, cachedLifetime);
115122

116123
if (mounted) setDeviceSampleCount(count);
117124
} catch (e) {
@@ -325,7 +332,7 @@ export default function ContributorSetup({ onStart, isSearchFocused, onSearchFoc
325332
<div className="text-base text-slate-400 mt-2">{leaveConfirmMsg || 'Clear saved contributor and start over?'}</div>
326333
<div className="mt-6 flex gap-3 justify-end">
327334
<button className="rounded-xl border border-verdigris-800 bg-verdigris-900 px-4 py-2 text-sm font-medium text-slate-300 hover:bg-verdigris-800" onClick={() => setShowLeaveConfirm(false)}>Cancel</button>
328-
<button className="rounded-xl bg-verdigris-100 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-verdigris-200" onClick={() => { deleteCookie('lipy_name'); deleteCookie('lipy_contributorId'); setName(''); setContributorId(''); setEditing(true); setShowLeaveConfirm(false); }}>Confirm</button>
335+
<button className="rounded-xl bg-verdigris-100 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-verdigris-200" onClick={() => { deleteCookie('lipy_name'); deleteCookie('lipy_contributorId'); clearContributorData(contributorId); setName(''); setContributorId(''); setEditing(true); setShowLeaveConfirm(false); }}>Confirm</button>
329336
</div>
330337
</div>
331338
</div>

frontend/lib/lipyd/datasetSyncService.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import db, { getPendingUploadCount, updateSampleSyncState, SampleRecord } from './storageService';
1+
import db, { getPendingUploadCount, updateSampleSyncState, SampleRecord, incrementLifetimeCount } from './storageService';
22
import { getSupabaseClient, isSupabaseConfigured } from './supabaseClient';
33
import { odiaCharacters } from './odiaCharacters';
44
import { ensureValidSessionId, isValidContributorName, isValidMode } from './validators';
@@ -286,6 +286,15 @@ async function markUploaded(item: any) {
286286
});
287287
}
288288
await db.uploadQueue.delete(item.clientSampleId);
289+
290+
// Increment the lifetime contribution count only after a successful upload.
291+
// This ensures the lifetime stat reflects *synchronized* samples, not just
292+
// locally-saved ones, and survives across sessions.
293+
if (item?.contributorId) {
294+
try {
295+
incrementLifetimeCount(item.contributorId);
296+
} catch (e) { }
297+
}
289298
}
290299

291300
async function markFailed(item: any, error: any) {

frontend/lib/lipyd/randomCharacterService.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { odiaCharacters, OdiaCharacter, CharacterType } from './odiaCharacters';
2-
import { getAllSamples } from './storageService';
2+
import { getAllSamples, getLifetimeCount, getContributorSampleCount } from './storageService';
33

44
const STATE_VERSION = 1;
55
const STORAGE_PREFIX = 'lipy_mixed_scheduler_state_v1_';
@@ -405,13 +405,13 @@ async function loadSessionTotals(sessionConfig: any) {
405405
}
406406
}
407407

408-
let cachedDev = 0;
409-
try {
410-
const devKey = `lipy_device_sample_count_${String(sessionConfig?.contributorId || '').trim()}`;
411-
cachedDev = Number(localStorage.getItem(devKey) || 0) || 0;
412-
} catch(e) {}
408+
// Lifetime count reflects all completed samples (durable across sessions).
409+
// Reconcile it with the IndexedDB count so the scheduler's baseline stays
410+
// accurate even if localStorage was cleared.
411+
const cachedLifetime = getLifetimeCount(sessionConfig?.contributorId || '');
412+
const dbCount = await getContributorSampleCount(sessionConfig?.contributorId || '');
413413

414-
return { datasetCounts, contributorCompletedCount: Math.max(contributorCompletedCount, cachedDev) };
414+
return { datasetCounts, contributorCompletedCount: Math.max(contributorCompletedCount, cachedLifetime, dbCount) };
415415
}
416416

417417
async function ensureSessionState(sessionConfig: any) {

frontend/lib/lipyd/storageService.ts

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Dexie, { Table } from 'dexie';
1+
import Dexie, { Table } from 'dexie';
22

33
export interface SampleRecord {
44
id?: number;
@@ -79,6 +79,11 @@ export class LiPyDatabase extends Dexie {
7979
contributors: '++id,contributorId,sessionId,[contributorId+sessionId]',
8080
uploadQueue: 'clientSampleId,contributorId,sessionId,characterId,status,nextAttemptAt,updatedAt',
8181
});
82+
this.version(5).stores({
83+
samples: '++id,clientSampleId,characterId,contributorId,sessionId,filename,timestamp,syncStatus,uploadedAt,[characterId+contributorId+sessionId],[contributorId+syncStatus]',
84+
contributors: '++id,contributorId,sessionId,[contributorId+sessionId]',
85+
uploadQueue: 'clientSampleId,contributorId,sessionId,characterId,status,nextAttemptAt,updatedAt',
86+
});
8287
}
8388
}
8489

@@ -102,13 +107,11 @@ export async function saveSample(sample: Omit<SampleRecord, 'id'>) {
102107
uploadedAt: sample?.uploadedAt || null,
103108
};
104109
const id = await db.samples.add(record);
110+
// Session statistics reset per session; lifetime statistics are incremented
111+
// only after a sample has been successfully synchronized (see
112+
// datasetSyncService.markUploaded -> incrementLifetimeCount).
105113
try {
106-
const devKey = `lipy_device_sample_count_${record.contributorId}`;
107114
const sessKey = `lipy_session_sample_count_${record.contributorId}_${record.sessionId}`;
108-
try {
109-
const prevDev = Number(localStorage.getItem(devKey) || 0) || 0;
110-
localStorage.setItem(devKey, String(prevDev + 1));
111-
} catch (e) { }
112115
try {
113116
const prevSess = Number(localStorage.getItem(sessKey) || 0) || 0;
114117
localStorage.setItem(sessKey, String(prevSess + 1));
@@ -163,4 +166,124 @@ export async function getPendingUploadCount() {
163166
return db.uploadQueue.count();
164167
}
165168

169+
/**
170+
* Lifetime contribution count helpers.
171+
*
172+
* The lifetime count represents the total number of samples a contributor has
173+
* *synchronized* (uploaded) across all sessions. It is stored in localStorage
174+
* under `lipy_device_sample_count_{contributorId}` and is incremented only
175+
* after a successful upload (see datasetSyncService.markUploaded). This
176+
* ensures the count persists across sessions, only ever increases on sync,
177+
* and is cleared by Reset Profile or by clearing browser storage.
178+
*/
179+
180+
export function getLifetimeCount(contributorId: string): number {
181+
if (!contributorId) return 0;
182+
try {
183+
const key = `lipy_device_sample_count_${String(contributorId).trim()}`;
184+
return Number(localStorage.getItem(key) || 0) || 0;
185+
} catch (e) {
186+
return 0;
187+
}
188+
}
189+
190+
export function incrementLifetimeCount(contributorId: string): number {
191+
if (!contributorId) return 0;
192+
try {
193+
const key = `lipy_device_sample_count_${String(contributorId).trim()}`;
194+
const prev = Number(localStorage.getItem(key) || 0) || 0;
195+
const next = prev + 1;
196+
localStorage.setItem(key, String(next));
197+
return next;
198+
} catch (e) {
199+
return 0;
200+
}
201+
}
202+
203+
export function clearLifetimeCount(contributorId: string) {
204+
if (!contributorId) return;
205+
try {
206+
const key = `lipy_device_sample_count_${String(contributorId).trim()}`;
207+
localStorage.removeItem(key);
208+
} catch (e) { }
209+
}
210+
211+
/**
212+
* Counts ALL samples in the local IndexedDB for a given contributor (across
213+
* all sessions, regardless of sync status).
214+
*/
215+
export async function getContributorSampleCount(contributorId: string): Promise<number> {
216+
if (!contributorId) return 0;
217+
try {
218+
const cid = String(contributorId).trim();
219+
return await db.samples.where('contributorId').equals(cid).count();
220+
} catch (e) {
221+
return 0;
222+
}
223+
}
224+
225+
/**
226+
* Counts only *uploaded* samples in the local IndexedDB for a given
227+
* contributor (across all sessions). Used to reconcile the displayed
228+
* lifetime contribution count so it reflects only successfully synchronized
229+
* samples.
230+
*/
231+
export async function getUploadedSampleCount(contributorId: string): Promise<number> {
232+
if (!contributorId) return 0;
233+
try {
234+
const cid = String(contributorId).trim();
235+
return await db.samples.where({ contributorId: cid, syncStatus: 'uploaded' }).count();
236+
} catch (e) {
237+
return 0;
238+
}
239+
}
240+
241+
/**
242+
* Clears ALL local data associated with a contributor profile:
243+
* - Lifetime contribution count (localStorage)
244+
* - Session config (localStorage)
245+
* - Session sample counts (localStorage)
246+
* - Last-sample timestamps (localStorage)
247+
* - Scheduler state (localStorage)
248+
* - Sample counter keys (localStorage)
249+
* - IndexedDB samples, contributors, and uploadQueue tables
250+
*
251+
* After calling this the contributor profile is fully reset.
252+
*/
253+
export async function clearContributorData(contributorId: string) {
254+
if (!contributorId) return;
255+
const cid = String(contributorId).trim();
256+
257+
// 1. Remove known localStorage keys for this contributor
258+
try {
259+
const keysToRemove: string[] = [];
260+
for (let i = 0; i < localStorage.length; i++) {
261+
const k = localStorage.key(i);
262+
if (k && (
263+
k.startsWith(`lipy_device_sample_count_${cid}`) ||
264+
k.startsWith(`lipy_session_sample_count_${cid}`) ||
265+
k.startsWith(`lipy_sample_counter_${cid}`) ||
266+
k.startsWith(`lipy_last_sample_ts_${cid}`) ||
267+
k.startsWith(`lipy_last_export_ts_${cid}`) ||
268+
k.startsWith(`lipy_mixed_scheduler_state_v1_${cid}`)
269+
)) {
270+
keysToRemove.push(k);
271+
}
272+
}
273+
keysToRemove.forEach((k) => localStorage.removeItem(k));
274+
} catch (e) { }
275+
276+
// 2. Remove session config (global, not contributor-scoped)
277+
try { localStorage.removeItem('lipy_session_config'); } catch (e) { }
278+
279+
// 3. Clear IndexedDB tables for this contributor
280+
try {
281+
await db.transaction('rw', db.samples, db.contributors, db.uploadQueue, async () => {
282+
await db.samples.where('contributorId').equals(cid).delete();
283+
await db.contributors.where('contributorId').equals(cid).delete();
284+
await db.uploadQueue.where('contributorId').equals(cid).delete();
285+
});
286+
} catch (e) { }
287+
}
288+
166289
export default db;

frontend/lib/lipyd/supabaseClient.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export function getSupabaseClient() {
2828
autoRefreshToken: false,
2929
persistSession: false,
3030
detectSessionInUrl: false,
31+
storage: {
32+
getItem: () => null,
33+
setItem: () => {},
34+
removeItem: () => {},
35+
},
3136
},
3237
});
3338
if (typeof window !== 'undefined') {

0 commit comments

Comments
 (0)