diff --git a/repostexchange-companion/README.md b/repostexchange-companion/README.md new file mode 100644 index 0000000..89f6199 --- /dev/null +++ b/repostexchange-companion/README.md @@ -0,0 +1,27 @@ +# Repost Companion — Local Queue + +A small Chrome Manifest V3 extension for keeping a local listening checklist and a record of reposts you complete yourself. + +## What it does + +- Keeps a queue of track titles and artist names on this device. +- Lets you mark a track listened to, then log the repost after you manually submit it. +- Shows progress toward a 20-repost local calendar-day target and the rolling 10-per-12-hour limit. +- Stores the queue and completion times in `chrome.storage.local`. + +Repost Companion never opens or reads RepostExchange or SoundCloud, controls playback, clicks repost buttons, writes comments, or runs in the background. Use it as a checklist beside the site; you make each platform decision and action yourself. The rolling limit reflects the [RepostExchange terms](https://repostexchange.com/terms-of-use); check the terms for updates. + +## Install locally + +1. Open `chrome://extensions` in Chrome. +2. Turn on **Developer mode**. +3. Choose **Load unpacked** and select this `repostexchange-companion` folder. +4. Pin **Repost Companion** to the toolbar. + +## Use + +Add a track and artist to your queue. Listen on RepostExchange yourself, then mark it listened. If you choose to repost it, submit the repost on RepostExchange yourself and only then select **I reposted it** in the extension. The local queue and count will update. + +Run the logic tests with `node --test tests/core.test.cjs`. Build the Chrome Web Store ZIP with `./scripts/package.sh`. + +See `store-listing.md` for the proposed listing and privacy disclosures. diff --git a/repostexchange-companion/dist/repost-companion-1.1.0.zip b/repostexchange-companion/dist/repost-companion-1.1.0.zip new file mode 100644 index 0000000..01c3579 Binary files /dev/null and b/repostexchange-companion/dist/repost-companion-1.1.0.zip differ diff --git a/repostexchange-companion/icons/icon-128.png b/repostexchange-companion/icons/icon-128.png new file mode 100644 index 0000000..959ddcb Binary files /dev/null and b/repostexchange-companion/icons/icon-128.png differ diff --git a/repostexchange-companion/icons/icon-16.png b/repostexchange-companion/icons/icon-16.png new file mode 100644 index 0000000..ce8925d Binary files /dev/null and b/repostexchange-companion/icons/icon-16.png differ diff --git a/repostexchange-companion/icons/icon-48.png b/repostexchange-companion/icons/icon-48.png new file mode 100644 index 0000000..51f72ad Binary files /dev/null and b/repostexchange-companion/icons/icon-48.png differ diff --git a/repostexchange-companion/icons/icon-master.png b/repostexchange-companion/icons/icon-master.png new file mode 100644 index 0000000..cd88371 Binary files /dev/null and b/repostexchange-companion/icons/icon-master.png differ diff --git a/repostexchange-companion/manifest.json b/repostexchange-companion/manifest.json new file mode 100644 index 0000000..dd17a0b --- /dev/null +++ b/repostexchange-companion/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 3, + "name": "Repost Companion — Local Queue", + "version": "1.1.0", + "description": "Keep a local manual listening checklist and repost progress log.", + "permissions": ["storage"], + "action": { + "default_title": "Repost Companion", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/repostexchange-companion/popup.css b/repostexchange-companion/popup.css new file mode 100644 index 0000000..6867c1f --- /dev/null +++ b/repostexchange-companion/popup.css @@ -0,0 +1,91 @@ +:root { + color-scheme: light; + --ink: #172724; + --muted: #667772; + --soft: #f4f6f3; + --line: #e5eae5; + --green: #155f4b; + --green-dark: #104c3c; + --lime: #c6f279; + --amber-bg: #fff3e8; + --white: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +* { box-sizing: border-box; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; } + +body { width: 410px; min-height: 580px; max-height: 640px; margin: 0; overflow-y: auto; background: var(--soft); color: var(--ink); } +.app-shell { padding: 19px 20px 16px; } +.topbar { display: flex; align-items: center; gap: 11px; padding-bottom: 18px; } +.brand-mark { width: 39px; height: 39px; border-radius: 13px; display: flex; align-items: center; justify-content: center; gap: 2px; background: var(--green); } +.brand-mark span { width: 3px; border-radius: 4px; background: var(--lime); } +.brand-mark span:nth-child(1), .brand-mark span:nth-child(5) { height: 8px; } +.brand-mark span:nth-child(2), .brand-mark span:nth-child(4) { height: 15px; } +.brand-mark span:nth-child(3) { height: 21px; } +.brand-copy { flex: 1; min-width: 0; } +.eyebrow { margin: 0 0 4px; color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .12em; } +h1, h2, p { margin-top: 0; } +h1 { margin: 0; font-size: 16px; letter-spacing: -.025em; } +.local-badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #dce5dd; border-radius: 99px; padding: 5px 8px; color: var(--muted); font-size: 9px; font-weight: 750; letter-spacing: .08em; } +.local-dot, .status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #4c9a69; } + +.progress-section { border: 1px solid var(--line); border-radius: 15px; padding: 14px; background: var(--white); box-shadow: 0 3px 10px rgba(27, 53, 43, .025); } +.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; } +.section-heading h2 { margin: 0; font-size: 17px; letter-spacing: -.035em; } +.date-label { padding-bottom: 1px; color: var(--muted); font-size: 11px; } +.progress-card { margin-top: 13px; } +.progress-topline { display: flex; justify-content: space-between; align-items: baseline; } +.progress-topline strong { font-size: 25px; letter-spacing: -.06em; } +.count-total { color: #87958f; font-size: 13px; } +.progress-caption { color: var(--muted); font-size: 11px; } +.progress-track { height: 7px; margin-top: 8px; overflow: hidden; border-radius: 9px; background: #edf1ed; } +.progress-track span { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--green); transition: width .2s ease; } +.rolling-row { display: flex; align-items: center; justify-content: space-between; margin-top: 11px; color: var(--muted); font-size: 10px; } +.rolling-row > span { display: flex; align-items: center; gap: 6px; } +.rolling-row strong { color: var(--ink); font-size: 10px; } +.status-dot.is-limit { background: #dc7a2d; } +.limit-banner { margin-top: 10px; border: 1px solid #f3d6bc; border-radius: 10px; padding: 9px 11px; background: var(--amber-bg); color: #874916; font-size: 11px; line-height: 1.45; } + +.form-section, .queue-section, .history-section { margin-top: 19px; } +.form-heading, .queue-heading, .history-heading { margin-bottom: 10px; } +.step-mark { color: var(--green); font-size: 11px; font-weight: 750; } +.step-mark i { color: #99aaa2; font-style: normal; font-weight: 500; } +.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; } +.field { display: flex; flex-direction: column; gap: 6px; color: #374943; font-size: 10px; font-weight: 700; } +.field input { width: 100%; height: 36px; border: 1px solid #dfe6df; border-radius: 9px; outline: none; padding: 0 10px; background: var(--white); color: var(--ink); font-size: 12px; font-weight: 450; transition: border-color .15s, box-shadow .15s; } +.field input:focus { border-color: #7bad93; box-shadow: 0 0 0 3px rgba(59, 134, 96, .11); } +.field input::placeholder { color: #a2aea8; } +.button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 34px; border: 0; border-radius: 9px; padding: 0 11px; font-size: 10px; font-weight: 700; transition: transform .12s, background .12s, opacity .12s; } +.button:not(:disabled):active { transform: translateY(1px); } +.button-primary { width: 100%; margin-top: 10px; background: var(--green); color: white; } +.button-primary:not(:disabled):hover { background: var(--green-dark); } +.button-primary:disabled, .button-log:disabled { opacity: .46; } +.manual-note { margin: 8px 0 0; color: #748078; font-size: 9px; line-height: 1.4; } + +.history-total { padding-bottom: 1px; color: #8a9791; font-size: 10px; white-space: nowrap; } +.queue-list, .history-list { display: flex; flex-direction: column; gap: 7px; } +.queue-item, .history-item { display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 1px solid var(--line); border-radius: 10px; padding: 9px; background: white; } +.queue-copy, .history-copy { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.queue-copy strong, .history-copy strong { overflow: hidden; color: #2c3e36; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.queue-copy span, .history-copy span { color: #89958f; font-size: 9px; } +.queue-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 5px; } +.button-step { min-height: 29px; padding: 0 8px; background: #e6f2e9; color: #24533d; } +.button-step:not(:disabled):hover { background: #d8ebdd; } +.button-step:disabled { opacity: .45; } +.button-log { background: var(--lime); color: #21341f; } +.button-log:not(:disabled):hover { background: #b7e768; } +.button-remove, .remove-entry { border: 0; background: transparent; color: #8e9b94; font-size: 9px; } +.button-remove:hover, .remove-entry:hover { color: #a14831; } +.history-heading h2 { font-size: 15px; } +.empty-state { margin: 0; border: 1px dashed #dbe2dc; border-radius: 10px; padding: 12px; color: #829089; font-size: 10px; line-height: 1.4; text-align: center; } +.footer-note { display: flex; align-items: center; justify-content: center; gap: 5px; margin-top: 16px; color: #87938d; font-size: 9px; } +.lock-mark { color: var(--green); font-size: 11px; } +.toast { position: fixed; right: 16px; bottom: 12px; left: 16px; z-index: 2; border-radius: 9px; padding: 9px 10px; transform: translateY(8px); background: #203c31; color: white; font-size: 10px; text-align: center; opacity: 0; pointer-events: none; transition: opacity .16s, transform .16s; } +.toast.is-visible { transform: translateY(0); opacity: 1; } +[hidden] { display: none !important; } diff --git a/repostexchange-companion/popup.html b/repostexchange-companion/popup.html new file mode 100644 index 0000000..edce660 --- /dev/null +++ b/repostexchange-companion/popup.html @@ -0,0 +1,78 @@ + + + + + + + Repost Companion + + + +
+
+ +
+

YOUR LOCAL CAMPAIGN DESK

+

Repost Companion

+
+ LOCAL +
+ +
+
+

TODAY

Your pace

+ +
+
+
+
0 / 20
+ reposts logged +
+
+
+ 12-hour window clear + 0 / 10 +
+
+
+ + + +
+
+

BUILD YOUR QUEUE

Add a track

+ 01 / 02 +
+
+ + +
+ +

You control the listening and reposting on RepostExchange. Mark each step here after you do it.

+
+ +
+
+

YOUR MANUAL CHECKLIST

Up next

+ 0 queued +
+
+

Add a track. You’ll play it and submit any repost yourself.

+
+ +
+
+

YOUR LOCAL LOG

Completed

+ 0 saved +
+
+

Manually completed reposts will appear here.

+
+ + +
+
+ + + + diff --git a/repostexchange-companion/scripts/package.sh b/repostexchange-companion/scripts/package.sh new file mode 100755 index 0000000..53192d3 --- /dev/null +++ b/repostexchange-companion/scripts/package.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +VERSION=$(node -e 'const fs=require("node:fs"); process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).version)' "$ROOT_DIR/manifest.json") +DIST_DIR="$ROOT_DIR/dist" +OUTPUT="$DIST_DIR/repost-companion-$VERSION.zip" + +mkdir -p "$DIST_DIR" +cd "$ROOT_DIR" +zip -q -r -X -FS "$OUTPUT" manifest.json popup.html popup.css src/core.js src/popup.js icons/icon-16.png icons/icon-48.png icons/icon-128.png +printf 'Created %s\n' "$OUTPUT" diff --git a/repostexchange-companion/scripts/render_icons.py b/repostexchange-companion/scripts/render_icons.py new file mode 100755 index 0000000..f1241fd --- /dev/null +++ b/repostexchange-companion/scripts/render_icons.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Create the extension's small PNG icons without external dependencies.""" + +from pathlib import Path +import struct +import zlib + + +ROOT = Path(__file__).resolve().parents[1] +ICON_DIR = ROOT / "icons" +MASTER_SIZE = 512 + + +def chunk(tag: bytes, data: bytes) -> bytes: + body = tag + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + +def in_round_rect(x: float, y: float, left: float, top: float, right: float, bottom: float, radius: float) -> bool: + cx = min(max(x, left + radius), right - radius) + cy = min(max(y, top + radius), bottom - radius) + return (x - cx) ** 2 + (y - cy) ** 2 <= radius**2 + + +def pixel_color(x: float, y: float) -> tuple[int, int, int, int]: + inset = 8 + radius = 105 + if in_round_rect(x, y, inset, inset, MASTER_SIZE - inset, MASTER_SIZE - inset, radius): + color = (21, 95, 75, 255) + else: + color = (0, 0, 0, 0) + + bar_centers = [171, 214, 256, 298, 341] + bar_heights = [78, 144, 202, 144, 78] + width = 24 + bar_color = (198, 242, 121, 255) + for center, height in zip(bar_centers, bar_heights): + top = (MASTER_SIZE - height) / 2 + bottom = top + height + if in_round_rect(x, y, center - width / 2, top, center + width / 2, bottom, width / 2): + color = bar_color + return color + + +def write_png(path: Path) -> None: + rows = bytearray() + for y in range(MASTER_SIZE): + rows.append(0) + for x in range(MASTER_SIZE): + rows.extend(pixel_color(x + 0.5, y + 0.5)) + header = struct.pack(">IIBBBBB", MASTER_SIZE, MASTER_SIZE, 8, 6, 0, 0, 0) + data = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b"") + path.write_bytes(data) + + +if __name__ == "__main__": + ICON_DIR.mkdir(parents=True, exist_ok=True) + write_png(ICON_DIR / "icon-master.png") diff --git a/repostexchange-companion/src/core.js b/repostexchange-companion/src/core.js new file mode 100644 index 0000000..9278c62 --- /dev/null +++ b/repostexchange-companion/src/core.js @@ -0,0 +1,57 @@ +(function attachCore(root, build) { + const api = build(); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.RepostCompanionCore = api; +})(typeof globalThis === "object" ? globalThis : this, function buildCore() { + const DAILY_TARGET = 20; + const ROLLING_LIMIT = 10; + const WINDOW_MS = 12 * 60 * 60 * 1000; + + function localDateKey(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + function parseCompletion(entry) { + const value = typeof entry === "number" ? entry : entry && entry.completedAt; + const timestamp = typeof value === "number" ? value : Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; + } + + function getProgress(entries, now = Date.now()) { + const list = Array.isArray(entries) ? entries : []; + const todayKey = localDateKey(now); + const completed = list + .map((entry) => ({ timestamp: parseCompletion(entry) })) + .filter(({ timestamp }) => timestamp !== null && timestamp <= now); + const todayCount = completed.filter(({ timestamp }) => localDateKey(timestamp) === todayKey).length; + const recent = completed + .filter(({ timestamp }) => now - timestamp < WINDOW_MS) + .sort((a, b) => a.timestamp - b.timestamp); + const nextSlotAt = recent.length >= ROLLING_LIMIT + ? recent[recent.length - ROLLING_LIMIT].timestamp + WINDOW_MS + : null; + + return { + todayCount, + dailyTarget: DAILY_TARGET, + rollingCount: recent.length, + rollingLimit: ROLLING_LIMIT, + nextSlotAt, + canLog: todayCount < DAILY_TARGET && recent.length < ROLLING_LIMIT + }; + } + + return Object.freeze({ + DAILY_TARGET, + ROLLING_LIMIT, + WINDOW_MS, + getProgress, + localDateKey, + parseCompletion + }); +}); diff --git a/repostexchange-companion/src/popup.js b/repostexchange-companion/src/popup.js new file mode 100644 index 0000000..c095801 --- /dev/null +++ b/repostexchange-companion/src/popup.js @@ -0,0 +1,253 @@ +(() => { + const core = globalThis.RepostCompanionCore; + const elements = { + title: document.querySelector("#track-title"), + artist: document.querySelector("#artist-name"), + add: document.querySelector("#add-button"), + dailyCount: document.querySelector("#daily-count"), + dailyStatus: document.querySelector("#daily-status"), + dailyFill: document.querySelector("#daily-fill"), + dailyProgress: document.querySelector("[role='progressbar']"), + todayLabel: document.querySelector("#today-label"), + rollingCount: document.querySelector("#rolling-count"), + rollingMessage: document.querySelector("#rolling-message"), + rollingDot: document.querySelector("#rolling-dot"), + limitBanner: document.querySelector("#limit-banner"), + queueList: document.querySelector("#queue-list"), + queueCount: document.querySelector("#queue-count"), + emptyQueue: document.querySelector("#empty-queue"), + historyList: document.querySelector("#history-list"), + historyCount: document.querySelector("#history-count"), + emptyHistory: document.querySelector("#empty-history"), + toast: document.querySelector("#toast") + }; + + const STORE_KEY = "repostCompanionData"; + let data = { entries: [], queue: [] }; + let toastTimer; + + function todayLabel(date = new Date()) { + return new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" }).format(date); + } + + function formatTime(timestamp) { + return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(timestamp)); + } + + function toTimestamp(value) { + const timestamp = typeof value === "number" ? value : Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : null; + } + + function countdown(target, now = Date.now()) { + const totalMinutes = Math.ceil(Math.max(0, target - now) / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return hours ? `${hours}h ${String(minutes).padStart(2, "0")}m` : `${minutes}m`; + } + + function toast(message) { + elements.toast.textContent = message; + elements.toast.classList.add("is-visible"); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => elements.toast.classList.remove("is-visible"), 2200); + } + + async function save() { + await chrome.storage.local.set({ [STORE_KEY]: data }); + } + + function updateProgress() { + const now = Date.now(); + const progress = core.getProgress(data.entries, now); + elements.todayLabel.textContent = todayLabel(new Date(now)); + elements.dailyCount.textContent = String(progress.todayCount); + elements.dailyStatus.textContent = progress.todayCount >= progress.dailyTarget ? "daily target reached" : "reposts logged"; + elements.dailyFill.style.width = `${Math.min(100, (progress.todayCount / progress.dailyTarget) * 100)}%`; + elements.dailyProgress.setAttribute("aria-valuenow", String(Math.min(progress.todayCount, progress.dailyTarget))); + elements.rollingCount.textContent = `${progress.rollingCount} / ${progress.rollingLimit}`; + elements.rollingDot.classList.toggle("is-limit", progress.rollingCount >= progress.rollingLimit); + + if (progress.rollingCount >= progress.rollingLimit && progress.nextSlotAt) { + elements.rollingMessage.textContent = `Next slot in ${countdown(progress.nextSlotAt, now)}`; + elements.limitBanner.hidden = false; + elements.limitBanner.textContent = `The 10-repost rolling limit is reached. Your next slot opens around ${formatTime(progress.nextSlotAt)}.`; + } else if (progress.todayCount >= progress.dailyTarget) { + elements.rollingMessage.textContent = "12-hour window clear"; + elements.limitBanner.hidden = false; + elements.limitBanner.textContent = "Daily target reached. Your completed activity is saved locally."; + } else { + elements.rollingMessage.textContent = "12-hour window clear"; + elements.limitBanner.hidden = true; + elements.limitBanner.textContent = ""; + } + + elements.add.disabled = !elements.title.value.trim() || !elements.artist.value.trim(); + } + + function makeTrackCopy(entry) { + const copy = document.createElement("div"); + copy.className = "queue-copy"; + const title = document.createElement("strong"); + title.textContent = entry.title; + const detail = document.createElement("span"); + detail.textContent = entry.status === "listened" ? `${entry.artist} · listened` : `${entry.artist} · ready to listen`; + copy.append(title, detail); + return copy; + } + + function makeButton(label, className, onClick, options = {}) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = label; + button.disabled = Boolean(options.disabled); + button.setAttribute("aria-label", options.ariaLabel || label); + button.addEventListener("click", onClick); + return button; + } + + function renderQueue() { + const sorted = [...data.queue].sort((a, b) => a.addedAt - b.addedAt); + const progress = core.getProgress(data.entries); + elements.queueList.replaceChildren(); + elements.queueCount.textContent = `${sorted.length} queued`; + elements.emptyQueue.hidden = sorted.length > 0; + + for (const entry of sorted) { + const row = document.createElement("article"); + row.className = "queue-item"; + row.append(makeTrackCopy(entry)); + const actions = document.createElement("div"); + actions.className = "queue-actions"; + + if (entry.status === "listened") { + const canLog = progress.canLog; + const disabledLabel = progress.todayCount >= progress.dailyTarget ? "Daily goal reached" : "Wait for slot"; + actions.append(makeButton( + canLog ? "I reposted it" : disabledLabel, + "button button-log", + () => completeRepost(entry.id), + { disabled: !canLog, ariaLabel: canLog ? `Log ${entry.title} after manually reposting it` : disabledLabel } + )); + } else { + actions.append(makeButton("I listened", "button button-step", () => markListened(entry.id))); + } + + actions.append(makeButton("Remove", "button-remove", () => removeQueuedTrack(entry.id), { ariaLabel: `Remove ${entry.title} from your queue` })); + row.append(actions); + elements.queueList.append(row); + } + } + + function renderHistory() { + const sorted = [...data.entries].sort((a, b) => b.completedAt - a.completedAt); + elements.historyList.replaceChildren(); + elements.historyCount.textContent = `${sorted.length} saved`; + elements.emptyHistory.hidden = sorted.length > 0; + + for (const entry of sorted.slice(0, 5)) { + const row = document.createElement("article"); + row.className = "history-item"; + const copy = document.createElement("div"); + copy.className = "history-copy"; + const title = document.createElement("strong"); + title.textContent = entry.title; + const detail = document.createElement("span"); + detail.textContent = `${entry.artist} · ${formatTime(entry.completedAt)}`; + copy.append(title, detail); + const remove = makeButton("Remove", "remove-entry", async () => { + data.entries = data.entries.filter((item) => item.id !== entry.id); + await save(); + render(); + toast("Removed from your local log"); + }, { ariaLabel: `Remove ${entry.title} from your local log` }); + row.append(copy, remove); + elements.historyList.append(row); + } + } + + function render() { + updateProgress(); + renderQueue(); + renderHistory(); + } + + async function addTrack() { + const title = elements.title.value.trim(); + const artist = elements.artist.value.trim(); + if (!title || !artist) return; + data.queue.push({ id: crypto.randomUUID(), title, artist, status: "queued", addedAt: Date.now() }); + await save(); + elements.title.value = ""; + elements.artist.value = ""; + render(); + elements.title.focus(); + toast("Added to your local queue"); + } + + async function markListened(id) { + const entry = data.queue.find((item) => item.id === id); + if (!entry) return; + entry.status = "listened"; + entry.listenedAt = Date.now(); + await save(); + render(); + toast("Marked listened — repost it yourself if you choose"); + } + + async function completeRepost(id) { + const progress = core.getProgress(data.entries); + const entry = data.queue.find((item) => item.id === id); + if (!entry || entry.status !== "listened" || !progress.canLog) return; + const completedAt = Date.now(); + data.entries.push({ id: crypto.randomUUID(), title: entry.title, artist: entry.artist, completedAt }); + data.queue = data.queue.filter((item) => item.id !== id); + await save(); + render(); + toast("Manual repost added to your local log"); + } + + async function removeQueuedTrack(id) { + data.queue = data.queue.filter((item) => item.id !== id); + await save(); + render(); + toast("Removed from your queue"); + } + + elements.title.addEventListener("input", updateProgress); + elements.artist.addEventListener("input", updateProgress); + elements.add.addEventListener("click", addTrack); + document.addEventListener("keydown", (event) => { + if (event.key === "Enter" && (event.target === elements.title || event.target === elements.artist) && !elements.add.disabled) addTrack(); + }); + + chrome.storage.local.get(STORE_KEY).then(async (stored) => { + const saved = stored[STORE_KEY]; + if (saved && typeof saved === "object") { + data = { + entries: Array.isArray(saved.entries) ? saved.entries.filter((entry) => entry && toTimestamp(entry.completedAt) !== null).map((entry, index) => ({ + id: typeof entry.id === "string" ? entry.id : `legacy-${index}-${entry.completedAt}`, + title: String(entry.title || "Untitled track"), + artist: String(entry.artist || "Unknown artist"), + completedAt: toTimestamp(entry.completedAt) + })) : [], + queue: Array.isArray(saved.queue) ? saved.queue.filter((entry) => entry && typeof entry.title === "string" && typeof entry.artist === "string").map((entry, index) => ({ + id: typeof entry.id === "string" ? entry.id : `queue-${index}-${entry.addedAt || Date.now()}`, + title: entry.title, + artist: entry.artist, + status: entry.status === "listened" ? "listened" : "queued", + addedAt: Number.isFinite(entry.addedAt) ? entry.addedAt : Date.now(), + ...(Number.isFinite(entry.listenedAt) ? { listenedAt: entry.listenedAt } : {}) + })) : [] + }; + await save(); + } + render(); + }).catch(() => { + render(); + toast("Couldn’t load local history"); + }); + + setInterval(render, 30000); +})(); diff --git a/repostexchange-companion/store-assets/README.md b/repostexchange-companion/store-assets/README.md new file mode 100644 index 0000000..3be0dac --- /dev/null +++ b/repostexchange-companion/store-assets/README.md @@ -0,0 +1,5 @@ +# Store asset status + +The extension icon, listing copy, and privacy policy draft are prepared. A genuine screenshot of the loaded Chrome extension is still required before a Chrome Web Store submission. + +The automated local review could not open Chrome's extension manager because the browser safety policy blocked `chrome://extensions`. The extension ZIP is ready for a manual local load; see the project README. After loading it in Chrome, capture the popup in a realistic, non-sensitive state and save it as `screenshot-01.png` in this directory. Do not include real track notes or account data in the store screenshot. diff --git a/repostexchange-companion/store-assets/privacy-policy.html b/repostexchange-companion/store-assets/privacy-policy.html new file mode 100644 index 0000000..5a1c187 --- /dev/null +++ b/repostexchange-companion/store-assets/privacy-policy.html @@ -0,0 +1,41 @@ + + + + + + Repost Companion Privacy Policy + + + +
+

Repost Companion Privacy Policy

+

Repost Companion is a local manual queue and campaign tracker. It does not read or modify RepostExchange or SoundCloud pages.

+ +

Information stored

+

When you use the extension, it stores track titles, artist names, queue status, and the completion times you enter. This information is kept in Chrome's storage.local on your device so the extension can display your checklist and progress.

+ +

Information sharing

+

The extension does not send this information to the developer or any other server. It contains no analytics, advertising, remote code, account service, or AI connection.

+ +

Permissions

+ +

Repost Companion requests no website access or host permissions.

+ +

Data removal

+

You can remove individual log entries using the extension. Removing the extension from Chrome removes its local extension data.

+ +

Changes and contact

+

This policy applies to Repost Companion version 1.1.0. A publisher support contact must be added here before public distribution.

+
+ + diff --git a/repostexchange-companion/store-listing.md b/repostexchange-companion/store-listing.md new file mode 100644 index 0000000..0798df5 --- /dev/null +++ b/repostexchange-companion/store-listing.md @@ -0,0 +1,40 @@ +# Chrome Web Store listing draft + +## Name + +Repost Companion — Local Queue + +## Short description + +Keep a private checklist of tracks listened to and reposts completed manually. + +## Detailed description + +Repost Companion is a small local checklist for a manual RepostExchange session. Add track titles and artists to a queue, mark tracks listened to, and log a repost only after you choose and submit it yourself. The popup shows your local daily progress and rolling 12-hour count. + +The extension stores its queue and completion timestamps in Chrome local storage on this device. It does not access RepostExchange or SoundCloud pages, control playback, click buttons, submit comments or reposts, or run in the background. It makes no network requests and has no account, analytics, advertising, or remote AI service. + +Repost Companion is an independent productivity tool and is not affiliated with or endorsed by RepostExchange or SoundCloud. + +## Single purpose + +Help users keep a local manual listening checklist and progress log for reposts they complete themselves. + +## Permission justification + +- `storage`: save the user's queue and completion timestamps locally in Chrome. + +The extension requests no host permissions and does not collect or transmit user data. + +## Privacy disclosure + +Repost Companion stores track titles, artist names, queue status, and completion timestamps in Chrome local storage on this device. This data is used only to display the checklist and calculate progress. It is not sent to the developer or any other server. The extension includes no analytics, advertising, account system, or remote code. + +## Store assets + +- 128 px icon: `icons/icon-128.png` +- Genuine product screenshot: pending local Chrome review. Do not submit this listing until a screenshot of the loaded extension has been added here. + +## Support and privacy policy + +Add a support contact and host `store-assets/privacy-policy.html` at a stable public URL before submitting. Add a genuine product screenshot. Do not claim Chrome Web Store approval until review is complete. diff --git a/repostexchange-companion/tests/core.test.cjs b/repostexchange-companion/tests/core.test.cjs new file mode 100644 index 0000000..bb26520 --- /dev/null +++ b/repostexchange-companion/tests/core.test.cjs @@ -0,0 +1,59 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const core = require("../src/core.js"); + +const HOUR = 60 * 60 * 1000; + +test("daily progress uses the machine's local calendar day", () => { + const now = new Date(2026, 8, 26, 12, 0, 0).getTime(); + const entries = [ + { completedAt: new Date(2026, 8, 26, 1, 0, 0).getTime() }, + { completedAt: new Date(2026, 8, 25, 23, 59, 0).getTime() }, + { completedAt: now + HOUR } + ]; + const progress = core.getProgress(entries, now); + assert.equal(progress.todayCount, 1); + assert.equal(progress.dailyTarget, 20); + assert.equal(progress.canLog, true); +}); + +test("the rolling cap includes the last twelve hours and frees the oldest slot at expiry", () => { + const now = new Date(2026, 8, 26, 12, 0, 0).getTime(); + const entries = Array.from({ length: 10 }, (_, index) => ({ completedAt: now - (11 - index) * HOUR })); + const atCap = core.getProgress(entries, now); + assert.equal(atCap.rollingCount, 10); + assert.equal(atCap.canLog, false); + assert.equal(atCap.nextSlotAt, entries[0].completedAt + core.WINDOW_MS); + + const afterExpiry = core.getProgress(entries, atCap.nextSlotAt); + assert.equal(afterExpiry.rollingCount, 9); + assert.equal(afterExpiry.canLog, true); +}); + +test("a completion exactly twelve hours old no longer counts toward the rolling window", () => { + const now = new Date(2026, 8, 26, 12, 0, 0).getTime(); + const entries = [{ completedAt: now - core.WINDOW_MS }]; + const progress = core.getProgress(entries, now); + assert.equal(progress.rollingCount, 0); + assert.equal(progress.canLog, true); +}); + +test("the daily target is counted for the local calendar day", () => { + const now = new Date(2026, 8, 26, 23, 30, 0).getTime(); + const first = new Date(2026, 8, 26, 0, 30, 0).getTime(); + const entries = Array.from({ length: 20 }, (_, index) => ({ completedAt: first + index * 70 * 60 * 1000 })); + const progress = core.getProgress(entries, now); + assert.equal(progress.todayCount, 20); + assert.equal(progress.rollingCount, 10); + assert.equal(progress.canLog, false); +}); + +test("invalid and future completion timestamps do not affect progress", () => { + const now = new Date(2026, 8, 26, 12, 0, 0).getTime(); + const progress = core.getProgress([ + { completedAt: "not-a-date" }, + { completedAt: now + HOUR } + ], now); + assert.equal(progress.todayCount, 0); + assert.equal(progress.rollingCount, 0); +});