diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..1d145b5 --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,49 @@ +name: Deploy preview + +on: + push: + branches-ignore: + - main + - gh-pages + +permissions: + contents: write + +concurrency: + group: 'pages-preview-${{ github.ref_name }}' + cancel-in-progress: true + +jobs: + deploy-preview: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: ./web/package-lock.json + + - name: Sanitize branch name + id: branch + run: | + SAFE="${{ github.ref_name }}" + SAFE="${SAFE//\//-}" + echo "safe=$SAFE" >> $GITHUB_OUTPUT + + - name: Install and Build + working-directory: ./web + run: | + npm ci + npm run build -- --base=/tasks/preview/${{ steps.branch.outputs.safe }} + + - name: Deploy preview to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./web/dist + publish_branch: gh-pages + destination_dir: preview/${{ steps.branch.outputs.safe }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b8827d..5c7731f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,9 +5,8 @@ on: branches: ['main'] workflow_dispatch: -# Only grant the necessary permissions permissions: - contents: write # Required for peaceiris/actions-gh-pages to push to gh-pages branch + contents: write concurrency: group: 'pages' @@ -33,6 +32,16 @@ jobs: npm ci npm run build + - name: Restore preview directory from gh-pages + run: | + git fetch origin gh-pages 2>/dev/null || true + if git rev-parse origin/gh-pages >/dev/null 2>&1; then + git checkout origin/gh-pages -- preview 2>/dev/null || true + if [ -d preview ]; then + cp -r preview ./web/dist/preview + fi + fi + - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: diff --git a/web/package-lock.json b/web/package-lock.json index 25a08f5..77a5073 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1171,6 +1172,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -1495,6 +1497,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1586,6 +1589,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.2.1.tgz", "integrity": "sha512-yBxFFs3zmkvKNmR0pFSU//rIsYjuX418TnlDmc2weaq5XFDqDIV/NOMPBoLrbxjLH42p4UzRuXHryXh9dYcKcw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -1607,6 +1611,7 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.5.tgz", "integrity": "sha512-ogI3DaFcyn6UhYhrgcyRAMbu/buBJitYQASZz5WzfQVPP10RD2AbCoRZ517psnezrasyCbWzIxZ6kVqet768xw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "^1.1.0", @@ -1713,6 +1718,7 @@ "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/web/src/components/AppRouter.tsx b/web/src/components/AppRouter.tsx index a372be7..bba9085 100644 --- a/web/src/components/AppRouter.tsx +++ b/web/src/components/AppRouter.tsx @@ -6,12 +6,6 @@ import { getBookNamesMap, getChapterData } from '../utils/dataUtils' import { groupByBook, groupByDay } from '../utils/groupUtils' import { useApi } from './ApiContext' import { createMemo } from 'solid-js' -import type { Tag } from '../data/model' - -const OT_NT = { - OT: 3, - NT: 2, -} as Record export function AppRouter() { const api = useApi() @@ -20,7 +14,7 @@ export function AppRouter() { const bookNames = getBookNamesMap(chapters) const bookGroups = groupByBook(chapters) const planGroups = createMemo(() => - groupByDay(chapters, api.getTags(), OT_NT), + groupByDay(chapters, api.getTags(), api.perDayTagData(), api.targetDays()), ) return ( diff --git a/web/src/components/PlanSettings.tsx b/web/src/components/PlanSettings.tsx index dc69250..d982145 100644 --- a/web/src/components/PlanSettings.tsx +++ b/web/src/components/PlanSettings.tsx @@ -72,6 +72,41 @@ export function PlanSettings() { +
+ + + +
+

Data

diff --git a/web/src/components/TagSelector.tsx b/web/src/components/TagSelector.tsx index 55824ea..2d7d234 100644 --- a/web/src/components/TagSelector.tsx +++ b/web/src/components/TagSelector.tsx @@ -24,12 +24,33 @@ export function TagSelector(props: TagSelectorProps) { } } + const onRemove = (tagName: Tag) => { + return () => { + props.onChange({ + ...props.value, + tags: props.value.tags.filter((t) => t !== tagName), + }) + } + } + return (
- + props.onChange({ ...props.value, count: +e.target.value })} + />
    - {(tagName) =>
  • {tagName}
  • }
    + + {(tagName) => ( +
  • + {tagName} + +
  • + )} +
  • ([ - { - tags: ['OT' as Tag], - count: 3, - }, - { - tags: ['NT' as Tag], - count: 2, - }, - ]) + const [perDayTagData, setPerDayTagData] = createSignal( + settingsData.perDayTagData, + ) this.perDayTagData = perDayTagData - this.setPerDayTagData = setPerDayTagData + this._setPerDayTagData = setPerDayTagData + + // targetDays signal + const [targetDays, setTargetDays] = createSignal(settingsData.targetDays) + this.targetDays = targetDays + this._setTargetDays = setTargetDays + + // cutoffDays signal + const [cutoffDays, setCutoffDays] = createSignal(settingsData.cutoffDays) + this.cutoffDays = cutoffDays + this._setCutoffDays = setCutoffDays + + // cutoffDate signal + const [cutoffDate, setCutoffDate] = createSignal(settingsData.cutoffDate) + this.cutoffDate = cutoffDate + this._setCutoffDate = setCutoffDate // searchText signal const [searchText, setSearchText] = createSignal('') @@ -80,15 +88,38 @@ export class Api { private readonly _settingsData: SettingsData private readonly _tagsData: TagRecord private readonly _setShowCompleted: Setter + private readonly _setTargetDays: Setter + private readonly _setCutoffDays: Setter + private readonly _setCutoffDate: Setter + private readonly _setPerDayTagData: Setter private readonly _setTimeStampMap: Setter readonly perDayTagData: Accessor - readonly setPerDayTagData: Setter + readonly targetDays: Accessor + readonly cutoffDays: Accessor + readonly cutoffDate: Accessor readonly searchText: Accessor readonly setSearchText: Setter readonly showCompleted: Accessor readonly timeStampMap: Accessor + private currentSettings = (): SettingsData => ({ + showCompleted: this.showCompleted(), + targetDays: this.targetDays(), + cutoffDays: this.cutoffDays(), + cutoffDate: this.cutoffDate(), + perDayTagData: this.perDayTagData(), + }) + + private effectiveCutoff = (): string | null => { + const days = this.cutoffDays() + const rolling = days != null + ? new Date(Date.now() - days * 86400000).toISOString().slice(0, 10) + : null + const candidates = [rolling, this.cutoffDate()].filter(Boolean) as string[] + return candidates.length ? candidates.toSorted().at(-1)! : null + } + getChapterData = (): ChapterData[] => { return this._chapterData } @@ -139,7 +170,10 @@ export class Api { abbrev, number, }: Pick) => { - return this.getChapterDates({ abbrev, number }).length > 0 + const dates = this.getChapterDates({ abbrev, number }) + const cutoff = this.effectiveCutoff() + if (!cutoff) return dates.length > 0 + return dates.some((d) => d.slice(0, 10) >= cutoff) } completeCount = (chapters: ChapterData[]): number => { @@ -173,9 +207,31 @@ export class Api { deleteTimeStamp(this._db, book, chapter, date) } + setPerDayTagData = async ( + valueOrUpdater: PerDayTagData[] | ((prev: PerDayTagData[]) => PerDayTagData[]), + ) => { + this._setPerDayTagData(valueOrUpdater as PerDayTagData[]) + await updateSettings(this._db, this.currentSettings()) + } + + setTargetDays = async (value: number) => { + this._setTargetDays(value) + await updateSettings(this._db, this.currentSettings()) + } + + setCutoffDays = async (value: number | null) => { + this._setCutoffDays(value) + await updateSettings(this._db, this.currentSettings()) + } + + setCutoffDate = async (value: string | null) => { + this._setCutoffDate(value) + await updateSettings(this._db, this.currentSettings()) + } + toggleShowCompleted = async () => { this._setShowCompleted((prev) => !prev) - await updateSettings(this._db, this.showCompleted()) + await updateSettings(this._db, this.currentSettings()) } exportData = (): string => { diff --git a/web/src/data/indexDb.ts b/web/src/data/indexDb.ts index 6d18bdb..d8c40af 100644 --- a/web/src/data/indexDb.ts +++ b/web/src/data/indexDb.ts @@ -2,7 +2,9 @@ import type { BookAbbrev, ChapterID, ISODateTimeString, + PerDayTagData, SettingsData, + Tag, TimeStampMap, } from './model' @@ -14,6 +16,10 @@ export type TimeStampKey = `${ISODateTimeString}_${BookAbbrev}_${ChapterID}` interface SettingsRecord { id: '1' showCompleted: boolean + targetDays?: number + cutoffDays?: number | null + cutoffDate?: string | null + perDayTagData?: PerDayTagData[] } interface TimestampRecord { @@ -110,10 +116,10 @@ export async function addTimestamp( } /** Update the settings record */ -export async function updateSettings(db: IDBDatabase, showCompleted: boolean) { +export async function updateSettings(db: IDBDatabase, settings: SettingsData) { const record: SettingsRecord = { id: '1', - showCompleted, + ...settings, } return putRecord(db, SETTINGS_STORE_NAME, record) @@ -157,6 +163,13 @@ export async function getSettingsData(db: IDBDatabase): Promise { const result = (event.target as IDBRequest).result resolve({ showCompleted: result?.showCompleted ?? true, + targetDays: result?.targetDays ?? 365, + cutoffDays: result?.cutoffDays ?? null, + cutoffDate: result?.cutoffDate ?? null, + perDayTagData: result?.perDayTagData ?? [ + { tags: ['OT' as Tag], count: 3 }, + { tags: ['NT' as Tag], count: 2 }, + ], }) } diff --git a/web/src/data/model.ts b/web/src/data/model.ts index 3be28d4..37655cd 100644 --- a/web/src/data/model.ts +++ b/web/src/data/model.ts @@ -32,6 +32,10 @@ export type TagRecord = Record> export interface SettingsData { showCompleted: boolean + targetDays: number + cutoffDays: number | null + cutoffDate: string | null + perDayTagData: PerDayTagData[] } export interface PerDayTagData { diff --git a/web/src/utils/groupUtils.ts b/web/src/utils/groupUtils.ts index 58450ad..127e904 100644 --- a/web/src/utils/groupUtils.ts +++ b/web/src/utils/groupUtils.ts @@ -1,5 +1,4 @@ -import type { BookName, ChapterData, Tag, TagRecord } from '../data/model' -import { keys } from './dataUtils' +import type { BookName, ChapterData, PerDayTagData, TagRecord } from '../data/model' export function groupByBook( data: ChapterData[], @@ -15,45 +14,27 @@ export function groupByBook( export function groupByDay( chapters: ChapterData[], tagRecord: TagRecord, - tagPerDay: Record, + perDayTagData: PerDayTagData[], + targetDays: number, ): Record { - console.log('[groupByDay]', { chapters, tagRecord, tagPerDay }) const groups: Record = {} - const dayTags = keys(tagPerDay) + const pools: ChapterData[][] = perDayTagData.map((entry) => + entry.tags.flatMap((tag) => chapters.filter((ch) => tagRecord[tag]?.[ch.abbrev])) + ) + const cursors: number[] = pools.map(() => 0) - let totalDays = 0 - const cursors: Record = {} - const tagChapters: Record = {} - - for (const tag of dayTags) { - cursors[tag] = 0 - tagChapters[tag] = chapters.filter((ch) => tagRecord[tag][ch.abbrev]) - - // total days is the minimum number of buckets needed to fit the longest tag - // chapter / tags per day count - totalDays = Math.max( - totalDays, - Math.ceil(tagChapters[tag].length / tagPerDay[tag]), - ) - } - - for (let i = 0; i < totalDays; i++) { + for (let day = 0; day < targetDays; day++) { const group: ChapterData[] = [] - - for (const tag of dayTags) { - for (let j = 0; j < tagPerDay[tag]; j++) { - const c = cursors[tag] % tagChapters[tag].length - // for last day, make sure we don't loop the longest tag - if (i === totalDays - 1 && c < cursors[tag]) { - break - } - cursors[tag] = c + 1 - group.push(tagChapters[tag][c]) + for (let i = 0; i < perDayTagData.length; i++) { + const pool = pools[i] + if (!pool.length) continue + for (let j = 0; j < perDayTagData[i].count; j++) { + group.push(pool[cursors[i] % pool.length]) + cursors[i]++ } } - - groups[`Day ${i + 1}`] = group + groups[`Day ${day + 1}`] = group } return groups