Improvoe focolive, add job views
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Cron } from '@nestjs/schedule';
|
||||||
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
import { InjectMetric } from '@willsoto/nestjs-prometheus';
|
||||||
import * as Airtable from 'airtable';
|
import * as Airtable from 'airtable';
|
||||||
import { AirtableBase } from 'airtable/lib/airtable_base';
|
import { AirtableBase } from 'airtable/lib/airtable_base';
|
||||||
@@ -23,6 +24,7 @@ export interface Event {
|
|||||||
"Music Start Time": string,
|
"Music Start Time": string,
|
||||||
"Bar or Venue Name": string,
|
"Bar or Venue Name": string,
|
||||||
"Band/DJ/Musician Name": string,
|
"Band/DJ/Musician Name": string,
|
||||||
|
"Cost Select": string;
|
||||||
Cost: string,
|
Cost: string,
|
||||||
"Date Select": string
|
"Date Select": string
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,12 @@ export class FocoLiveService {
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Cron("0 */5 * * * *")
|
||||||
|
async refreshEvents() {
|
||||||
|
this.logger.verbose("Refreshing events cache.");
|
||||||
|
await this.getAllEventsCached();
|
||||||
|
}
|
||||||
|
|
||||||
async getEvents(options: { venue?: string, before?: Date, after?: Date } = {}) {
|
async getEvents(options: { venue?: string, before?: Date, after?: Date } = {}) {
|
||||||
const events = await this.getAllEventsCached();
|
const events = await this.getAllEventsCached();
|
||||||
const results = pipe(
|
const results = pipe(
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Header, Headers, Param, Post, Render, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
import { JobsService } from './jobs.service';
|
import { JobMetadata, JobsService } from './jobs.service';
|
||||||
import { ApiBody, ApiConsumes, ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
|
||||||
class ClaimCompleteDto {
|
class ClaimCompleteDto {
|
||||||
@@ -30,7 +31,7 @@ export class JobsController {
|
|||||||
private readonly jobsService: JobsService,
|
private readonly jobsService: JobsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@Get(':jobName/stats')
|
@Get(':jobName/stats.json')
|
||||||
@ApiParam({ name: 'jobName', required: true })
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
async getStats(@Param('jobName') jobName: string) {
|
async getStats(@Param('jobName') jobName: string) {
|
||||||
return {
|
return {
|
||||||
@@ -40,18 +41,105 @@ export class JobsController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':jobName/todo')
|
@Get(':jobName/stats')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@Render('jobs/stats')
|
||||||
|
async getStatsPage(@Param('jobName') jobName: string) {
|
||||||
|
return {
|
||||||
|
jobName,
|
||||||
|
todoCount: await this.jobsService.getTodoItemCount(jobName),
|
||||||
|
claimedCount: await this.jobsService.getClaimedItemCount(jobName),
|
||||||
|
doneCount: await this.jobsService.getDoneItemCount(jobName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobName/todo.json')
|
||||||
@ApiParam({ name: 'jobName', required: true })
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
async getTodoItems(@Param('jobName') jobName: string) {
|
async getTodoItems(@Param('jobName') jobName: string) {
|
||||||
return this.jobsService.getTodoItems(jobName);
|
return this.jobsService.getTodoItems(jobName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':jobName/leaderboard')
|
@Get(':jobName/done.json')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
async getDone(@Param('jobName') jobName: string) {
|
||||||
|
return this.jobsService.getDoneItems(jobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobName/claimed.json')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
async getClaimed(@Param('jobName') jobName: string) {
|
||||||
|
return this.jobsService.getClaimedItems(jobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobName/leaderboard.json')
|
||||||
@ApiParam({ name: 'jobName', required: true })
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
async getLeaderboard(@Param('jobName') jobName: string) {
|
async getLeaderboard(@Param('jobName') jobName: string) {
|
||||||
return this.jobsService.getLeaderboard(jobName);
|
return this.jobsService.getLeaderboard(jobName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':jobName/leaderboard')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@Render('jobs/leaderboard')
|
||||||
|
async getLeaderboardPage(@Param('jobName') jobName: string) {
|
||||||
|
const leaderboard = await this.jobsService.getLeaderboard(jobName);
|
||||||
|
type LeaderboardItem = { name: string, count: number }
|
||||||
|
return {
|
||||||
|
jobName,
|
||||||
|
claims: Object.keys(leaderboard.claimCounts).map(claimer => ({ name: claimer, count: leaderboard.claimCounts[claimer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count),
|
||||||
|
completes: Object.keys(leaderboard.completeCounts).map(completer => ({ name: completer, count: leaderboard.completeCounts[completer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':jobName/register')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Job metadata", examples:
|
||||||
|
{
|
||||||
|
"example1": {
|
||||||
|
value: {
|
||||||
|
name: 'my-job',
|
||||||
|
description: 'My job description',
|
||||||
|
tags: ['tag1', 'tag2'],
|
||||||
|
createdBy: 'my-identity',
|
||||||
|
createdAt: '2021-01-01T00:00:00Z',
|
||||||
|
claimSecret: 'my-secret'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ApiConsumes('application/json')
|
||||||
|
async claimJobItemJson(@Param('jobName') jobName: string, @Body() body: JobMetadata) {
|
||||||
|
return this.jobsService.registerJob(jobName, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobName.json')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
async getJobMetadata(@Param('jobName') jobName: string) {
|
||||||
|
const metadata = await this.jobsService.getPublicJobMetadata(jobName);
|
||||||
|
if (!metadata) {
|
||||||
|
return {
|
||||||
|
error: 'Job not registered',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':jobName/clear-todo')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@ApiBearerAuth('x-claim-key')
|
||||||
|
async clearTodoItems(@Headers('x-claim-key') claimKey: string, @Param('jobName') jobName: string) {
|
||||||
|
return this.jobsService.clearTodoItems(jobName, claimKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':jobName/add')
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@Render('jobs/add')
|
||||||
|
async addItemsPage(@Param('jobName') jobName: string) {
|
||||||
|
return {
|
||||||
|
jobName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':jobName/add')
|
@Post(':jobName/add')
|
||||||
@ApiParam({ name: 'jobName', required: true })
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
@ApiConsumes('text/plain')
|
@ApiConsumes('text/plain')
|
||||||
@@ -60,12 +148,29 @@ export class JobsController {
|
|||||||
return this.jobsService.addItemsToJob(jobName, items.split('\n'));
|
return this.jobsService.addItemsToJob(jobName, items.split('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':jobName/addFile')
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({ description: "File containing items to add, one per line separated by newline characters" })
|
||||||
|
async addItemsToJobFromFile(@Param('jobName') jobName: string, @UploadedFile() file: Express.Multer.File) {
|
||||||
|
return this.jobsService.addItemsToJob(
|
||||||
|
jobName,
|
||||||
|
file.buffer.toString().split('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':jobName/claim')
|
@Post(':jobName/claim')
|
||||||
@ApiParam({ name: 'jobName', required: true })
|
@ApiParam({ name: 'jobName', required: true })
|
||||||
@ApiConsumes('text/plain')
|
@ApiConsumes('text/plain')
|
||||||
@ApiBody({ type: String, description: "Claimer identity string" })
|
@ApiBody({ type: String, description: "Claimer identity string" })
|
||||||
async claimJobItem(@Param('jobName') jobName: string, @Body() claimer: string) {
|
async claimJobItem(@Param('jobName') jobName: string, @Body() claimer: string) {
|
||||||
return this.jobsService.claimJobItem(jobName, claimer);
|
const item = await this.jobsService.claimJobItem(jobName, claimer);
|
||||||
|
return {
|
||||||
|
jobName,
|
||||||
|
claimer,
|
||||||
|
item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':jobName/complete')
|
@Post(':jobName/complete')
|
||||||
|
@@ -4,6 +4,22 @@ import Redis from 'ioredis'
|
|||||||
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
import { InjectRedis } from '@liaoliaots/nestjs-redis';
|
||||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
import { Cache } from 'cache-manager';
|
import { Cache } from 'cache-manager';
|
||||||
|
import { aperture } from 'ramda';
|
||||||
|
|
||||||
|
export interface JobMetadata {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
claimSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const privateMetadataKeys = ['claimSecret'];
|
||||||
|
|
||||||
|
export type PublicJobMetadata = Omit<JobMetadata, typeof privateMetadataKeys[number]>;
|
||||||
|
|
||||||
|
type Leaderboard = { completeCounts: { [claimer: string]: number }, claimCounts: { [claimer: string]: number } }
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JobsService {
|
export class JobsService {
|
||||||
@@ -13,6 +29,10 @@ export class JobsService {
|
|||||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
public static cleanJobMetadata(metadata: JobMetadata): PublicJobMetadata {
|
||||||
|
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !privateMetadataKeys.includes(key))) as PublicJobMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
private jobNameBuilder(jobName: string) {
|
private jobNameBuilder(jobName: string) {
|
||||||
return `job:${jobName}`;
|
return `job:${jobName}`;
|
||||||
}
|
}
|
||||||
@@ -37,7 +57,11 @@ export class JobsService {
|
|||||||
return `claim:${jobName}:${claimer}`;
|
return `claim:${jobName}:${claimer}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getCompleteCounts(jobName: string) {
|
private claimerCountWildcardBuilder(jobName: string) {
|
||||||
|
return `claim:${jobName}:*`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCompleteCounts(jobName: string): Promise<{ [claimer: string]: number }> {
|
||||||
const keys = await this.redis.keys(`complete:${jobName}:*`);
|
const keys = await this.redis.keys(`complete:${jobName}:*`);
|
||||||
const counts = await Promise.all(keys.map(async key => {
|
const counts = await Promise.all(keys.map(async key => {
|
||||||
const count = await this.redis.get(key);
|
const count = await this.redis.get(key);
|
||||||
@@ -55,7 +79,7 @@ export class JobsService {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getClaimCounts(jobName: string) {
|
private async getClaimCounts(jobName: string): Promise<{ [claimer: string]: number }> {
|
||||||
const keys = await this.redis.keys(`claim:${jobName}:*`);
|
const keys = await this.redis.keys(`claim:${jobName}:*`);
|
||||||
const counts = await Promise.all(keys.map(async key => {
|
const counts = await Promise.all(keys.map(async key => {
|
||||||
const count = await this.redis.get(key);
|
const count = await this.redis.get(key);
|
||||||
@@ -73,8 +97,8 @@ export class JobsService {
|
|||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeaderboard(jobName: string) {
|
async getLeaderboard(jobName: string): Promise<Leaderboard> {
|
||||||
const cachedLeaderboard = await this.cacheManager.get(`leaderboard:${jobName}`);
|
const cachedLeaderboard = await this.cacheManager.get<Leaderboard>(`leaderboard:${jobName}`);
|
||||||
if (cachedLeaderboard) {
|
if (cachedLeaderboard) {
|
||||||
return cachedLeaderboard;
|
return cachedLeaderboard;
|
||||||
}
|
}
|
||||||
@@ -86,24 +110,30 @@ export class JobsService {
|
|||||||
|
|
||||||
|
|
||||||
async addItemsToJob(jobName: string, items: string[]) {
|
async addItemsToJob(jobName: string, items: string[]) {
|
||||||
await this.redis.rpush(this.todoListNameBuilder(jobName), ...items);
|
const apertureSize = 100
|
||||||
|
for (const itemSubset of (items.length > apertureSize ? aperture(apertureSize, items) : [items])) {
|
||||||
|
await this.redis.rpush(this.todoListNameBuilder(jobName), ...itemSubset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async claimJobItem(jobName: string, claimer: string): Promise<string | null> {
|
async claimJobItem(jobName: string, claimer: string): Promise<string | null> {
|
||||||
const jobItem = await this.redis.brpoplpush(this.todoListNameBuilder(jobName), this.claimedListNameBuilder(jobName), 10);
|
const jobItem = await this.redis.brpoplpush(this.todoListNameBuilder(jobName), this.claimedListNameBuilder(jobName), 10);
|
||||||
if (jobItem) {
|
if (!jobItem) {
|
||||||
await this.redis.rpush(this.jobNameBuilder(jobName), JSON.stringify({ item: jobItem, client: claimer }));
|
return null;
|
||||||
}
|
}
|
||||||
await this.redis.incr(this.claimerCountNameBuilder(jobName, claimer));
|
await this.redis.incr(this.claimerCountNameBuilder(jobName, claimer));
|
||||||
return jobItem;
|
return jobItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeJobItem(jobName: string, jobItem: string, completer: string, data: any) {
|
async completeJobItem(jobName: string, jobItem: string, completer: string, data: any) {
|
||||||
await this.redis.lrem(this.claimedListNameBuilder(jobName), 1, jobItem);
|
const claimRemoveResult = await this.redis.lrem(this.claimedListNameBuilder(jobName), 1, jobItem);
|
||||||
await this.redis.lrem(this.todoListNameBuilder(jobName), 1, JSON.stringify({ item: jobItem, client: completer }));
|
if (claimRemoveResult === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await this.redis.rpush(this.doneListNameBuilder(jobName), JSON.stringify({ item: jobItem, client: completer, data }));
|
await this.redis.rpush(this.doneListNameBuilder(jobName), JSON.stringify({ item: jobItem, client: completer, data }));
|
||||||
await this.redis.decr(this.claimerCountNameBuilder(jobName, completer));
|
await this.redis.decr(this.claimerCountNameBuilder(jobName, completer));
|
||||||
await this.redis.incr(this.completeCountNameBuilder(jobName, completer));
|
await this.redis.incr(this.completeCountNameBuilder(jobName, completer));
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTodoItems(jobName: string) {
|
async getTodoItems(jobName: string) {
|
||||||
@@ -134,11 +164,19 @@ export class JobsService {
|
|||||||
return this.redis.keys('job:*');
|
return this.redis.keys('job:*');
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerJob(jobName: string, metadata: any) {
|
async isJobRegistered(jobName: string) {
|
||||||
await this.redis.set(this.jobNameBuilder(jobName), JSON.stringify(metadata));
|
return this.redis.exists(this.jobNameBuilder(jobName));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobMetadata(jobName: string): Promise<any | null> {
|
async registerJob(jobName: string, metadata: JobMetadata) {
|
||||||
|
if (await this.isJobRegistered(jobName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await this.redis.set(this.jobNameBuilder(jobName), JSON.stringify(metadata));
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobMetadata(jobName: string): Promise<JobMetadata | null> {
|
||||||
const result = await this.redis.get(this.jobNameBuilder(jobName))
|
const result = await this.redis.get(this.jobNameBuilder(jobName))
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return null;
|
return null;
|
||||||
@@ -146,11 +184,34 @@ export class JobsService {
|
|||||||
return JSON.parse(result)
|
return JSON.parse(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPublicJobMetadata(jobName: string): Promise<PublicJobMetadata | null> {
|
||||||
|
const metadata = await this.getJobMetadata(jobName);
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JobsService.cleanJobMetadata(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearClaimerCounts(jobName: string) {
|
||||||
|
const keys = await this.redis.keys(this.claimerCountWildcardBuilder(jobName));
|
||||||
|
await Promise.all(keys.map(key => this.redis.del(key)));
|
||||||
|
}
|
||||||
|
|
||||||
async resetClaimedItems(jobName: string) {
|
async resetClaimedItems(jobName: string) {
|
||||||
const claimedItems = await this.getClaimedItems(jobName);
|
const claimedItems = await this.getClaimedItems(jobName);
|
||||||
for (const claimedItem of claimedItems) {
|
for (const claimedItem of claimedItems) {
|
||||||
await this.redis.rpoplpush(this.claimedListNameBuilder(jobName), this.todoListNameBuilder(jobName));
|
await this.redis.rpoplpush(this.claimedListNameBuilder(jobName), this.todoListNameBuilder(jobName));
|
||||||
}
|
}
|
||||||
|
await this.clearClaimerCounts(jobName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearTodoItems(jobName: string, claimKey: string) {
|
||||||
|
const metadata = await this.getJobMetadata(jobName);
|
||||||
|
if (metadata === null || metadata.claimSecret !== claimKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.del(this.todoListNameBuilder(jobName));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
14
views/jobs/add.hbs
Normal file
14
views/jobs/add.hbs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<form
|
||||||
|
method='post'
|
||||||
|
action='/jobs/{{jobName}}/addFile'
|
||||||
|
enctype='multipart/form-data'
|
||||||
|
>
|
||||||
|
<p>File will be split on newline characters</p>
|
||||||
|
<div>
|
||||||
|
<label for='file'>Choose file to upload</label>
|
||||||
|
<input type='file' id='file' name='file' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button>Submit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
21
views/jobs/leaderboard.hbs
Normal file
21
views/jobs/leaderboard.hbs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<div>
|
||||||
|
<div>Job: {{jobName}}</div>
|
||||||
|
<div style='display: flex; flex-direction: row;'>
|
||||||
|
<div>
|
||||||
|
<h2>Complete</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each completes as |item|}}
|
||||||
|
<li>{{name}}: {{count}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Claims</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each claims as |item|}}
|
||||||
|
<li>{{name}}: {{count}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
15
views/jobs/stats.hbs
Normal file
15
views/jobs/stats.hbs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<div>
|
||||||
|
<div>Job: {{jobName}}</div>
|
||||||
|
<div style='display: flex; flex-direction: row;'>
|
||||||
|
<div>ToDo: </div>
|
||||||
|
<div>{{todoCount}}</div>
|
||||||
|
</div>
|
||||||
|
<div style='display: flex; flex-direction: row;'>
|
||||||
|
<div>Claimed: </div>
|
||||||
|
<div>{{claimedCount}}</div>
|
||||||
|
</div>
|
||||||
|
<div style='display: flex; flex-direction: row;'>
|
||||||
|
<div>Done: </div>
|
||||||
|
<div>{{doneCount}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
Reference in New Issue
Block a user