Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4211.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- POST streaks and activity statistics now use the user's local day for legacy timestamped practice.
36 changes: 26 additions & 10 deletions server/lib/postStreak.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
* circular dependency back into meatspacePost.js.
*/

// A date key is always the local `YYYY-MM-DD` prefix. Session dates are stored
// that way already; some training-log entries (memory practice) store a full
// ISO timestamp, so normalize both to the day prefix before any set math.
export function normalizeYmd(value) {
import { toUserDayKey } from './activeDays.js';

/**
* Normalize a stored day label or legacy ISO instant.
*
* @param {unknown} value - a bare day label or full ISO timestamp
* @param {string} [timezone] - user timezone for re-keying legacy instants
* @returns {string|null} a day key, or null for an absent/invalid timezone-aware value
*/
export function normalizeYmd(value, timezone) {
if (!value) return null;
return String(value).split('T')[0];
const raw = String(value);
return timezone && raw.includes('T') ? toUserDayKey(raw, timezone) : raw.split('T')[0];
}

// Local-date arithmetic on `YYYY-MM-DD` strings via UTC midnight so day math
Expand All @@ -42,15 +49,19 @@ export function ymdShift(s, deltaDays) {
* - `longestStreak` — longest consecutive-day run in all history
* - `lastDate` — most recent record date (null if never active)
* - `todayScore` — best record score recorded today (null if none)
*
* @param {Array} records - activity records with a `date` field
* @param {string} todayStr - user's local `YYYY-MM-DD` today
* @param {string} [timezone] - user timezone for legacy ISO dates
*/
export function computePostStreaks(records, todayStr) {
const dateSet = new Set((records || []).map(s => normalizeYmd(s?.date)).filter(Boolean));
export function computePostStreaks(records, todayStr, timezone) {
const dateSet = new Set((records || []).map(s => normalizeYmd(s?.date, timezone)).filter(Boolean));
const dates = Array.from(dateSet).sort();
const completedToday = dateSet.has(todayStr);
const lastDate = dates.length ? dates[dates.length - 1] : null;

const todayScores = (records || [])
.filter(s => normalizeYmd(s?.date) === todayStr && typeof s?.score === 'number')
.filter(s => normalizeYmd(s?.date, timezone) === todayStr && typeof s?.score === 'number')
.map(s => s.score);
const todayScore = todayScores.length ? Math.max(...todayScores) : null;

Expand Down Expand Up @@ -80,12 +91,17 @@ export function computePostStreaks(records, todayStr) {
* memory practice). Reuses `computePostStreaks` so the DST-safe grace-window
* semantics are identical to the scored-session streak. Returns the progress-API
* shape (`current` / `longest` / `lastActiveDate`).
*
* @param {Array} sessions - scored POST sessions
* @param {Array} trainingEntries - training-log entries
* @param {string} todayStr - user's local `YYYY-MM-DD` today
* @param {string} [timezone] - user timezone for legacy ISO dates
*/
export function computeUnifiedStreak(sessions, trainingEntries, todayStr) {
export function computeUnifiedStreak(sessions, trainingEntries, todayStr, timezone) {
const activity = [
...(sessions || []).map(s => ({ date: s?.date })),
...(trainingEntries || []).map(e => ({ date: e?.date })),
];
const { currentStreak, longestStreak, lastDate } = computePostStreaks(activity, todayStr);
const { currentStreak, longestStreak, lastDate } = computePostStreaks(activity, todayStr, timezone);
return { current: currentStreak, longest: longestStreak, lastActiveDate: lastDate };
}
22 changes: 22 additions & 0 deletions server/lib/postStreak.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ describe('computePostStreaks (shared helper)', () => {
expect(r.lastDate).toBe('2026-06-28');
});

it('keys legacy instants to the user-local day while preserving bare day labels', () => {
// The pair crosses UTC midnight: it is still July 17 in Los Angeles, but July 17 and 18
// in Tokyo. The same local boundary must drive both the streak and active-days tile.
const legacy = [
rec('2026-07-17T09:00:00.000Z'),
rec('2026-07-18T02:00:00.000Z'),
];

const behindUtc = computePostStreaks(legacy, '2026-07-18', 'America/Los_Angeles');
expect(behindUtc).toMatchObject({ currentStreak: 1, longestStreak: 1, lastDate: '2026-07-17' });

const aheadOfUtc = computePostStreaks(legacy, '2026-07-18', 'Asia/Tokyo');
expect(aheadOfUtc).toMatchObject({ currentStreak: 2, longestStreak: 2, lastDate: '2026-07-18' });

const bareLabels = computePostStreaks(
[rec('2026-07-17'), rec('2026-07-18')],
'2026-07-18',
'America/Los_Angeles'
);
expect(bareLabels).toMatchObject({ currentStreak: 2, longestStreak: 2, lastDate: '2026-07-18' });
});

it('honors the grace window (today not done, yesterday done)', () => {
const r = computePostStreaks([rec('2026-06-26'), rec('2026-06-27')], '2026-06-28');
expect(r.completedToday).toBe(false);
Expand Down
7 changes: 6 additions & 1 deletion server/routes/scaffold.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { request } from '../lib/testHelper.js';

// --- Mock the filesystem + subprocess boundary so we can assert ZERO
// mutations happen when a request fails validation (issue #2390). ---
// PATHS.data is required here too: scaffold.js pulls in lib/validation.js ->
// subscriptionSavings.js -> postStreak.js -> activeDays.js -> timezone.js ->
// services/settings.js, whose module-scope `join(PATHS.data, 'settings.json')`
// throws on load if this mock omits PATHS (#4211 added the activeDays.js edge).
vi.mock('../lib/fileUtils.js', () => ({
ensureDir: vi.fn().mockResolvedValue(undefined),
expandHome: (p) => p
expandHome: (p) => p,
PATHS: { data: '/mock/data' }
}));

vi.mock('fs/promises', () => ({
Expand Down
9 changes: 8 additions & 1 deletion server/services/agentTuiSpawning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,14 @@ vi.mock('../lib/fileUtils.js', async (importOriginal) => ({
// agentSentinel.parseSentinelPayload, etc.); only stub the I/O + PATHS.
...(await importOriginal()),
tryReadFile: vi.fn().mockResolvedValue(null),
PATHS: { root: '/tmp/portos-root' }
// `data` is required alongside `root`: this module pulls in taskTypeHooks.js
// -> ... -> lib/validation.js -> subscriptionSavings.js -> postStreak.js ->
// activeDays.js -> timezone.js -> services/settings.js, whose module-scope
// `join(PATHS.data, 'settings.json')` throws on load without it (#4211
// added the activeDays.js edge). The bare `PATHS: {...}` below fully
// replaces the real object (it doesn't merge), so every member this graph
// needs at import time has to be listed explicitly.
PATHS: { root: '/tmp/portos-root', data: '/tmp/portos-root/data' }
}));

vi.mock('../lib/providerModels.js', async (importOriginal) => ({
Expand Down
14 changes: 5 additions & 9 deletions server/services/characterMetrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,13 @@ export const METRICS = [
// day boundary) the Progress page and the dashboard widgets use, so the Character sheet
// can't quote a different streak than the rest of PortOS (#2091).
compute: async (read) => {
const [sessions, training, today] = await Promise.all([
const [sessions, training, today, timezone] = await Promise.all([
read('postSessions'),
read('postTraining'),
read('postToday'),
read('userTimezone'),
]);
return computeUnifiedStreak(sessions, training, today).current;
return computeUnifiedStreak(sessions, training, today, timezone).current;
},
},
{
Expand All @@ -115,13 +116,8 @@ export const METRICS = [
// documents exactly what it can and cannot fix — notably that a stored bare day LABEL is
// taken as authored, because no instant survives to re-derive it from.
//
// KNOWN DIVERGENCE (#4211): `postStreakDays` above resolves its day keys through
// `postStreak.js`'s `normalizeYmd`, which still takes the UTC day (`split('T')[0]`). For
// the bare `YYYY-MM-DD` keys every writer has stamped since #2681 the two agree exactly;
// they can disagree only on a LEGACY full-ISO entry straddling UTC midnight, where this
// tile is the correct one and the streak is not. Aligning the shared streak helper is
// cross-cutting (it feeds the Progress page and the dashboard widgets too), so it is
// tracked separately rather than smuggled in here.
// The shared streak helper re-keys legacy full-ISO entries through the same user-local
// boundary as this union, while bare `YYYY-MM-DD` labels remain authored day keys.
//
// Missing keys are a FAILED read, not an idle install: each source is validated as an
// array and a throw here lands in `readMetric`'s `unavailable`, so a shape mismatch can
Expand Down
13 changes: 13 additions & 0 deletions server/services/characterMetrics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ describe('getCharacterMetrics — populated domains', () => {
expect(userLocalToday).toHaveBeenCalled();
});

it('re-keys legacy POST instants with the same timezone as Days Active', async () => {
stats.today = '2026-07-18';
stats.timezone = 'America/Los_Angeles';
stats.training = [
{ date: '2026-07-17T09:00:00.000Z' },
{ date: '2026-07-18T02:00:00.000Z' },
];

expect(byId(await getCharacterMetrics(), 'postStreakDays')).toMatchObject({
value: 1, unavailable: false,
});
});

it('reports a broken POST streak as a real 0, not as unavailable', async () => {
stats.today = '2026-07-17';
stats.sessions = [{ date: '2026-01-01' }]; // active once, long ago
Expand Down
9 changes: 5 additions & 4 deletions server/services/characterSignals.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,11 @@ export const SIGNAL_READERS = {
// a day for any user whose configured timezone isn't the server's.
postToday: () => userLocalToday(),
// The user's IANA timezone itself, for consumers that must re-key a stored INSTANT to the
// user's day rather than just compare against today (`daysActive` — see
// `server/lib/activeDays.js`). Same settings read `postToday` bottoms out in, so keeping it
// as its own signal costs one extra `getSettings()` per request and keeps the day-boundary
// decision in ONE place instead of forking a second "which day is this?" implementation.
// user's day rather than just compare against today (`daysActive` and `postStreakDays` — see
// `server/lib/activeDays.js` and `server/lib/postStreak.js`). Same settings read `postToday`
// bottoms out in, so keeping it as its own signal costs one extra `getSettings()` per request
// and keeps the day-boundary decision in ONE place instead of forking a second "which day is
// this?" implementation.
userTimezone: () => getUserTimezone(),
// `withActiveDayKeys` (#4120): the raw union of stored health-log day keys, which the
// cross-domain `daysActive` metric unions with POST's days. Opt-in at the service so the
Expand Down
Loading