/* ── Text export ── */
/**
* Exports the currently viewed day's log as a plaintext file.
* Groups entries by category and task, includes a header with day start/end
* times, tracked/billable/internal totals, and the implied (untracked) break
* time; lists each task's individual tracked sessions as time ranges plus any
* plan-task/entry notes and proof links as indented lines; finishes with a
* pasteable per-item summary line and, when anything looks off (undocumented
* work, an unbroken >4h block, a full day with no break-sized gap), a
* warnings section. Writes to the user's chosen save folder via the File
* System Access API, or falls back to a browser download.
*/
function exportTxt() {
const dayEntries = viewEntries().slice().reverse();
if (!dayEntries.length) return;
const dateStr = dk(getViewDate());
const isViewingToday = dateStr === dk(new Date());
const timedEntries = dayEntries.filter(
(entry) => entry.tsEnd && entry.tsEnd > entry.ts && entry.signifier !== 'cancelled'
);
const { dayStartTs, dayEndTs } = computeDayBounds(dayEntries, timedEntries, {
isViewingToday,
dayStart: isViewingToday ? getDayStart() : null,
activeTimer: getActiveTimer(),
now: Date.now(),
});
const fmtTsHM = (ts) => {
const d = new Date(ts);
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
};
const fmtSessionRange = (session) => `${fmtTsHM(session.ts)}–${fmtTsHM(session.tsEnd)}`;
// Body: tracked time grouped by category, then task (first-seen order),
// with each task's individual sessions, plan/entry notes, and proof links
// appended as indented lines.
const { catOrder, catGrouped } = groupEntriesByCategory(dayEntries);
const taskNotes = mergeNoteMaps(
buildTaskNoteMap(getPlanTasks(), dateStr),
buildEntryNoteMap(dayEntries)
);
const taskLinks = buildEntryLinkMap(dayEntries);
const lines = formatGroupedLines(
catOrder,
catGrouped,
fmtDurLong,
getCatLabel,
taskNotes,
taskLinks,
fmtSessionRange
);
// Billable / non-billable breakdown. Entries are annotated with their
// resolved billable status up front so both the header totals and the
// summary line (below) read from the same source of truth.
const entriesWithBillingStatus = annotateBillableStatus(timedEntries);
const totalTrackedMs = entriesWithBillingStatus.reduce(
(sum, entry) => sum + (entry.tsEnd - entry.ts),
0
);
const billableMs = entriesWithBillingStatus
.filter((entry) => entry._billable)
.reduce((sum, entry) => sum + (entry.tsEnd - entry.ts), 0);
const nonBillableMs = totalTrackedMs - billableMs;
const workdaySpanMs = dayStartTs && dayEndTs ? dayEndTs - dayStartTs : 0;
const untrackedMs = workdaySpanMs ? Math.max(0, workdaySpanMs - totalTrackedMs) : 0;
const header = [`Work Log — ${dateStr}`];
if (dayStartTs) {
const startStr = fmtTsHM(dayStartTs);
const endStr = dayEndTs ? fmtTsHM(dayEndTs) : '--:--';
header.push(`Started: ${startStr} | Ended: ${endStr}`);
if (dayEndTs) header.push(`Workday: ${fmtDurLong(workdaySpanMs)}`);
}
if (totalTrackedMs > 0) {
header.push(
`Total tracked: ${fmtDurLong(totalTrackedMs)} | 💰 Billable: ${fmtDurLong(billableMs)} | 💸 Internal: ${fmtDurLong(nonBillableMs)}`
);
}
if (workdaySpanMs > 0) {
header.push(`Breaks (untracked): ${fmtDurLong(untrackedMs)}`);
}
header.push('---');
// Pasteable summary — last line of the file. One "Label (duration)" item
// per distinct task/category/billable combination, totalled across the
// whole day, billable and internal alike (internal ones marked).
const summaryLine = buildTimesheetSummaryLine(entriesWithBillingStatus, fmtDurLong);
// Gap/anomaly warnings — flags issues before anyone else has to ask about
// them. Reuses entriesWithBillingStatus's resolved _billable flags so
// non-billable entries aren't flagged for a missing note/link.
const warnings = findExportWarnings(entriesWithBillingStatus, {
workdaySpanMs,
untrackedMs,
fmtDuration: fmtDurLong,
});
const warningLines = warnings.length
? ['---', '⚠ Warnings:', ...warnings.map((warning) => ` - ${warning}`)]
: [];
const blob = new Blob(
[
[...header, ...lines, ...(summaryLine ? ['---', summaryLine] : []), ...warningLines].join(
'\n'
),
],
{ type: 'text/plain' }
);
const filename = `work-log-${dateStr}.txt`;
writeExportFile('timesheets', filename, blob);
}
/* ── JSON backup / restore / merge ── */
/**
* Reads and parses an optional JSON-array log from localStorage for inclusion
* in a backup. If the stored value is present but not valid JSON, the failure
* is logged via {@link wlLog} (so the data loss is diagnosable rather than
* silent) and an empty array is returned so the rest of the backup still
* succeeds. Absent keys legitimately yield an empty array.
* @param {string} storeKey - The localStorage key to read.
* @param {string} label - Human-readable log name for the warning message.
* @returns {Array} The parsed array, or `[]` if absent or unparseable.
*/
function readOptionalLogForBackup(storeKey, label) {
try {
return JSON.parse(localStorage.getItem(storeKey) || '[]');
} catch (err) {
wlLog.warn(
`exportBackup: ${label} in localStorage is not valid JSON — backing up an empty array for it; the corrupt data is excluded from this backup`,
err
);
return [];
}
}
/** How many days of entries to include in each backup file. */
const BACKUP_RETENTION_DAYS = 21;
/**
* Exports a JSON backup of recent application state: entries, plan tasks, time
* blocks, dev log, and distractions from the last {@link BACKUP_RETENTION_DAYS}
* days, plus the (undated) categories and hidden quick-pick items and the
* source-capped pomodoro log. Every time-series array is trimmed to the
* retention window so the file size stays bounded; older records remain in
* earlier backup files. Triggers a file download or writes to the save folder.
*/
function exportBackup() {
const { payload, dropped } = buildBackupPayload(
{
entries: getEntries(),
categories: getCategories(),
planTasks: getPlanTasks(),
blocks: getBlocks(),
pomoLog: readOptionalLogForBackup(STORE_POMO_LOG, 'pomoLog'),
devLog: readOptionalLogForBackup(STORE_DEV_LOG, 'devLog'),
distractions: readOptionalLogForBackup(STORE_DISTRACTIONS, 'distractions'),
qpHidden,
},
BACKUP_RETENTION_DAYS,
Date.now()
);
const droppedPairs = Object.entries(dropped);
if (droppedPairs.length) {
const total = droppedPairs.reduce((sum, [, n]) => sum + n, 0);
const detail = droppedPairs.map(([label, n]) => `${label}: ${n}`).join(', ');
wlLog.info(
`exportBackup: excluded ${total} record${total === 1 ? '' : 's'} outside the ${BACKUP_RETENTION_DAYS}-day retention window or with a missing/invalid date (${detail})`
);
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const filename = `work-log-backup-${BACKUP_RETENTION_DAYS}d-${dk(new Date())}.json`;
writeExportFile('JSON backups', filename, blob);
}
/**
* Restores application state from a JSON backup file previously created by
* {@link exportBackup}. Reads the file, validates its structure via
* {@link validateBackupFile}, asks the user to confirm, writes all arrays to
* their localStorage keys, then reloads the page so state is re-initialised
* cleanly from the restored data.
*
* Assumption: import is a full replace, not a merge. All data currently in the
* affected localStorage keys is overwritten after the user confirms. If
* selective-date merging is ever needed, add a merge mode option here and update
* the confirmation dialog accordingly.
*
* @param {File} file - The .json backup file selected by the user.
* @returns {Promise<void>}
*/
async function importBackup(file) {
let text;
try {
text = await file.text();
} catch (err) {
wlLog.warn('importBackup: failed to read file', err);
alert('Could not read the file. Please try again.');
return;
}
let backup;
try {
backup = JSON.parse(text);
} catch (err) {
wlLog.warn('importBackup: file is not valid JSON', err);
alert('The selected file is not valid JSON. Please choose a work-log backup file.');
return;
}
const { valid, error } = validateBackupFile(backup);
if (!valid) {
alert(error);
return;
}
const entryCount = backup.entries.length;
const dates = backup.entries
.map((entry) => entry.date)
.filter(Boolean)
.sort();
const dateRange =
dates.length > 0 ? `${dates[0]} to ${dates[dates.length - 1]}` : 'no dated entries';
const exportedAt = backup.exported ? new Date(backup.exported).toLocaleString() : 'unknown date';
const confirmed = window.confirm(
`Restore backup from ${exportedAt}?\n\n` +
`${entryCount} entries (${dateRange})\n` +
`${backup.categories.length} categories\n` +
`${backup.planTasks.length} tasks\n\n` +
`⚠️ This will replace your current data. The page will reload after import.`
);
if (!confirmed) return;
try {
// Write primary arrays — always present (validated above)
localStorage.setItem(STORE_ENTRIES, JSON.stringify(backup.entries));
localStorage.setItem(STORE_CATS, JSON.stringify(backup.categories));
// STORE_PLAN is defined in 10-tasks.js; safe to reference at call-time
localStorage.setItem(STORE_PLAN, JSON.stringify(backup.planTasks));
// Write optional arrays — only if present in the backup
// STORE_BLOCKS is defined in 11-timeblock.js
if (Array.isArray(backup.blocks)) {
localStorage.setItem(STORE_BLOCKS, JSON.stringify(backup.blocks));
}
if (Array.isArray(backup.pomoLog)) {
localStorage.setItem(STORE_POMO_LOG, JSON.stringify(backup.pomoLog));
}
// STORE_DEV_LOG is defined in 12b-changelog-data.js
if (Array.isArray(backup.devLog)) {
localStorage.setItem(STORE_DEV_LOG, JSON.stringify(backup.devLog));
}
// STORE_DISTRACTIONS is defined in 12-misc.js
if (Array.isArray(backup.distractions)) {
localStorage.setItem(STORE_DISTRACTIONS, JSON.stringify(backup.distractions));
}
if (Array.isArray(backup.qpHidden)) {
localStorage.setItem(STORE_QP_HIDDEN, JSON.stringify(backup.qpHidden));
}
wlLog.info(
`importBackup: restored ${entryCount} entries, ` +
`${backup.categories.length} categories, ` +
`${backup.planTasks.length} tasks ` +
`from backup exported ${backup.exported ?? 'unknown'}`
);
location.reload();
} catch (err) {
wlLog.warn('importBackup: failed to write to localStorage', err);
alert(
'Import failed — could not write to localStorage. Your existing data has not been changed.'
);
}
}
/**
* Merges entries from a JSON backup file into the current data set without
* replacing any existing records. Only entries whose `id` is not already
* present in localStorage are added. Categories, tasks, and all other backup
* fields are ignored — this is an entries-only operation.
*
* Intended for the "worked on another machine" case: the backup from the
* remote session contains new entries that are absent from the local store.
*
* @param {File} file - The .json backup file selected by the user.
* @returns {Promise<void>}
*/
async function mergeBackupEntries(file) {
let text;
try {
text = await file.text();
} catch (err) {
wlLog.warn('mergeBackupEntries: failed to read file', err);
alert('Could not read the file. Please try again.');
return;
}
let backup;
try {
backup = JSON.parse(text);
} catch (err) {
wlLog.warn('mergeBackupEntries: file is not valid JSON', err);
alert('The selected file is not valid JSON. Please choose a work-log backup file.');
return;
}
const { valid, error } = validateBackupFile(backup);
if (!valid) {
alert(error);
return;
}
const incoming = filterNewBackupEntries(getEntries(), backup.entries, validEntry);
if (!incoming.length) {
alert('No new entries found — all entries in this backup already exist in your current data.');
return;
}
const dates = [...new Set(incoming.map((e) => e.date).filter(Boolean))].sort();
const exportedAt = backup.exported ? new Date(backup.exported).toLocaleString() : 'unknown date';
const confirmed = window.confirm(
`Merge ${incoming.length} new entr${incoming.length === 1 ? 'y' : 'ies'} from backup?\n\n` +
`Backup exported: ${exportedAt}\n` +
`Dates: ${dates.join(', ')}\n\n` +
'Your existing entries will not be changed. The page will reload after merging.'
);
if (!confirmed) return;
try {
const merged = [...getEntries(), ...incoming];
localStorage.setItem(STORE_ENTRIES, JSON.stringify(merged));
wlLog.info(
`mergeBackupEntries: merged ${incoming.length} new entries ` +
`(dates: ${dates.join(', ')}) from backup exported ${backup.exported ?? 'unknown'}`
);
location.reload();
} catch (err) {
wlLog.warn('mergeBackupEntries: failed to write to localStorage', err);
alert(
'Merge failed — could not write to localStorage. Your existing data has not been changed.'
);
}
}